Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
351 changes: 205 additions & 146 deletions api/uexecutor/v1/query.pulsar.go

Large diffs are not rendered by default.

246 changes: 170 additions & 76 deletions api/uregistry/v1/types.pulsar.go

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions app/upgrades.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
contractauditchanges "github.com/pushchain/push-chain-node/app/upgrades/contract-audit-changes"
evmparamsmigration "github.com/pushchain/push-chain-node/app/upgrades/evm-params-migration"
evmchainidffix "github.com/pushchain/push-chain-node/app/upgrades/evm-chainid-fix"
evmpreinstalls "github.com/pushchain/push-chain-node/app/upgrades/evm-preinstalls"
ethhashfix "github.com/pushchain/push-chain-node/app/upgrades/eth-hash-fix"
evmrpcfix "github.com/pushchain/push-chain-node/app/upgrades/evm-rpc-fix"
feeabs "github.com/pushchain/push-chain-node/app/upgrades/fee-abs"
Expand Down Expand Up @@ -71,6 +72,7 @@ var Upgrades = []upgrades.Upgrade{
contractauditchanges.NewUpgrade(),
evmparamsmigration.NewUpgrade(),
evmchainidffix.NewUpgrade(),
evmpreinstalls.NewUpgrade(),
}

// RegisterUpgradeHandlers registers the chain upgrade handlers
Expand Down
82 changes: 82 additions & 0 deletions app/upgrades/evm-preinstalls/upgrade.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package evmpreinstalls

import (
"context"
"fmt"

storetypes "cosmossdk.io/store/types"
upgradetypes "cosmossdk.io/x/upgrade/types"

sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/module"
"github.com/cosmos/evm/x/vm/statedb"
evmtypes "github.com/cosmos/evm/x/vm/types"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/holiman/uint256"

"github.com/pushchain/push-chain-node/app/upgrades"
)

const UpgradeName = "evm-preinstalls"

func NewUpgrade() upgrades.Upgrade {
return upgrades.Upgrade{
UpgradeName: UpgradeName,
CreateUpgradeHandler: CreateUpgradeHandler,
StoreUpgrades: storetypes.StoreUpgrades{
Added: []string{},
Deleted: []string{},
},
}
}

func CreateUpgradeHandler(
mm upgrades.ModuleManager,
configurator module.Configurator,
keepers *upgrades.AppKeepers,
) upgradetypes.UpgradeHandler {
return func(ctx context.Context, _ upgradetypes.Plan, fromVM module.VersionMap) (module.VersionMap, error) {
sdkCtx := sdk.UnwrapSDKContext(ctx)
logger := sdkCtx.Logger().With("upgrade", UpgradeName)
logger.Info("Starting upgrade handler")

versionMap, err := mm.RunMigrations(ctx, configurator, fromVM)
if err != nil {
return nil, fmt.Errorf("RunMigrations: %w", err)
}

if err := deployCreate2Factory(sdkCtx, keepers); err != nil {
return nil, fmt.Errorf("deployCreate2Factory: %w", err)
}

logger.Info("Upgrade complete", "upgrade", UpgradeName)
return versionMap, nil
}
}

func deployCreate2Factory(ctx sdk.Context, keepers *upgrades.AppKeepers) error {
logger := ctx.Logger().With("migration", "evm-preinstalls")

address := common.HexToAddress("0x4e59b44847b379578588920ca78fbf26c0b4956c")
code := common.FromHex("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3")
codeHash := crypto.Keccak256Hash(code).Bytes()

if evmtypes.IsEmptyCodeHash(codeHash) {
return fmt.Errorf("create2 factory has empty code hash")
}

if err := keepers.EVMKeeper.SetAccount(ctx, address, statedb.Account{
Nonce: 0,
Balance: new(uint256.Int),
CodeHash: codeHash,
}); err != nil {
return fmt.Errorf("SetAccount: %w", err)
}

keepers.EVMKeeper.SetCodeHash(ctx, address.Bytes(), codeHash)
keepers.EVMKeeper.SetCode(ctx, codeHash, code)

logger.Info("Create2 factory deployed", "address", address.Hex())
return nil
}
1 change: 1 addition & 0 deletions proto/uexecutor/v1/query.proto
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ message PendingOutboundEntry {
string outbound_id = 1;
string universal_tx_id = 2;
int64 created_at = 3;
int64 signing_deadline = 4; // unix timestamp after which the TSS signature expires on the destination chain (0 = no expiry)
}

message QueryGetPendingOutboundRequest {
Expand Down
2 changes: 2 additions & 0 deletions proto/uregistry/v1/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ message ChainConfig {
google.protobuf.Duration gas_oracle_fetch_interval = 8 [(gogoproto.nullable) = false, (gogoproto.stdduration) = true]; // how often relayers should fetch gas prices

repeated VaultMethods vault_methods = 9; // List of methods exposed by the vault contract (optional)

google.protobuf.Duration tss_signing_deadline = 10 [(gogoproto.stdduration) = true]; // duration added to block time to compute the signature expiry deadline on the destination chain (zero = no expiry)
}

message NativeRepresentation {
Expand Down
10 changes: 7 additions & 3 deletions universalClient/chains/common/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,13 @@ type TxBuilder interface {

// IsAlreadyExecuted checks whether a transaction with the given txID has already been
// executed on the destination chain (e.g., by another relayer).
// For SVM: checks if the ExecutedTx PDA exists on-chain.
// For EVM: returns false (EVM uses nonce-based replay protection).
IsAlreadyExecuted(ctx context.Context, txID string) (bool, error)
// For SVM: checks if the ExecutedTx PDA exists on-chain, AND returns the
// unix timestamp of the latest finalized block. Callers use this as the
// cluster's "now" to gate deadline-based give-up/REVERT decisions and to
// detect cluster halt or finalization stall (queryBlockTime far behind
// wall-clock). 0 means freshness couldn't be determined.
// For EVM: returns (false, 0, nil). EVM uses nonce-based replay protection.
IsAlreadyExecuted(ctx context.Context, txID string) (executed bool, queryBlockTime int64, err error)

// GetGasFeeUsed returns the gas fee used by a transaction on the destination chain.
// EVM: fetches receipt and returns gasUsed * effectiveGasPrice as decimal string.
Expand Down
13 changes: 8 additions & 5 deletions universalClient/chains/evm/tx_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ func NewTxBuilder(
return tb, nil
}

// GetOutboundSigningRequest creates a signing request from outbound event data
// GetOutboundSigningRequest creates a signing request from outbound event data.
// EVM doesn't consume data.SigningDeadline — deadlines are SVM-only; EVM relies
// on nonce-based finality.
func (tb *TxBuilder) GetOutboundSigningRequest(
ctx context.Context,
data *uetypes.OutboundCreatedEvent,
Expand Down Expand Up @@ -439,10 +441,11 @@ func parseGasLimit(gasLimitStr string) (*big.Int, error) {
return gasLimit, nil
}

// IsAlreadyExecuted returns false for EVM. EVM uses nonce-based replay protection,
// checked via GetNextNonce in the broadcaster.
func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, error) {
return false, nil
// IsAlreadyExecuted returns (false, 0, nil) for EVM. EVM uses nonce-based
// replay protection (checked via GetNextNonce in the broadcaster); the
// cluster-time signal is SVM-only.
func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, int64, error) {
return false, 0, nil
}

// GetGasFeeUsed returns the gas fee used by a transaction on the EVM chain.
Expand Down
11 changes: 7 additions & 4 deletions universalClient/chains/evm/tx_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -549,27 +549,30 @@ func TestFinalizeUniversalTxUnifiedEncoding(t *testing.T) {
}
}

// TestIsAlreadyExecuted tests the stub that always returns false
// TestIsAlreadyExecuted tests the stub that always returns (false, 0, nil)
func TestIsAlreadyExecuted(t *testing.T) {
builder := newTestTxBuilder(t)
ctx := context.Background()

t.Run("always returns false", func(t *testing.T) {
executed, err := builder.IsAlreadyExecuted(ctx, "0x1234567890abcdef")
executed, queryBlockTime, err := builder.IsAlreadyExecuted(ctx, "0x1234567890abcdef")
assert.NoError(t, err)
assert.False(t, executed)
assert.Equal(t, int64(0), queryBlockTime)
})

t.Run("returns false for empty txID", func(t *testing.T) {
executed, err := builder.IsAlreadyExecuted(ctx, "")
executed, queryBlockTime, err := builder.IsAlreadyExecuted(ctx, "")
assert.NoError(t, err)
assert.False(t, executed)
assert.Equal(t, int64(0), queryBlockTime)
})

t.Run("returns false for arbitrary txID", func(t *testing.T) {
executed, err := builder.IsAlreadyExecuted(ctx, "any-string-at-all")
executed, queryBlockTime, err := builder.IsAlreadyExecuted(ctx, "any-string-at-all")
assert.NoError(t, err)
assert.False(t, executed)
assert.Equal(t, int64(0), queryBlockTime)
})
}

Expand Down
2 changes: 1 addition & 1 deletion universalClient/chains/push/event_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ func convertTssEvent(tssEvent *utsstypes.TssEvent) (*store.Event, error) {
}, nil
}


// convertFundMigrationEvent converts a FundMigration to a store.Event.
func convertFundMigrationEvent(migration *utsstypes.FundMigration) (*store.Event, error) {
if migration == nil {
Expand Down Expand Up @@ -138,6 +137,7 @@ func convertOutboundToEvent(entry *uexecutortypes.PendingOutboundEntry, outbound
PcTxHash: pcTxHash,
LogIndex: logIndex,
RevertMsg: revertMsg,
SigningDeadline: entry.SigningDeadline,
}

eventData, err := json.Marshal(outboundData)
Expand Down
99 changes: 70 additions & 29 deletions universalClient/chains/push/event_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,42 +245,54 @@ func TestConvertOutboundToEvent(t *testing.T) {
assert.Equal(t, "3", data.LogIndex)
assert.Empty(t, data.RevertMsg)
})
}

func TestDefaultExpiryOffset(t *testing.T) {
assert.Equal(t, uint64(600), uint64(DefaultExpiryOffset))
}

func TestHashEventID(t *testing.T) {
t.Run("deterministic output", func(t *testing.T) {
id1 := hashEventID("keygen", "123")
id2 := hashEventID("keygen", "123")
assert.Equal(t, id1, id2)
t.Run("both nil returns error", func(t *testing.T) {
result, err := convertOutboundToEvent(nil, nil)
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "entry or outbound is nil")
})

t.Run("different types produce different IDs", func(t *testing.T) {
id1 := hashEventID("keygen", "123")
id2 := hashEventID("refresh", "123")
assert.NotEqual(t, id1, id2)
})
t.Run("chain-supplied signing deadline flows through", func(t *testing.T) {
entry := &uexecutortypes.PendingOutboundEntry{
OutboundId: "0xabc",
UniversalTxId: "utx-deadline",
CreatedAt: 1000,
SigningDeadline: 1735689600,
}
outbound := &uexecutortypes.OutboundTx{
Id: "0xabc",
DestinationChain: "solana:devnet",
Amount: "1",
}

t.Run("different raw IDs produce different IDs", func(t *testing.T) {
id1 := hashEventID("keygen", "1")
id2 := hashEventID("keygen", "2")
assert.NotEqual(t, id1, id2)
})
result, err := convertOutboundToEvent(entry, outbound)
require.NoError(t, err)

t.Run("output is hex string of sha256 length", func(t *testing.T) {
id := hashEventID("type", "id")
assert.Len(t, id, 64) // sha256 = 32 bytes = 64 hex chars
var data uexecutortypes.OutboundCreatedEvent
require.NoError(t, json.Unmarshal(result.EventData, &data))
assert.Equal(t, int64(1735689600), data.SigningDeadline)
})
}

func TestConvertOutboundToEvent_BothNil(t *testing.T) {
result, err := convertOutboundToEvent(nil, nil)
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "entry or outbound is nil")
t.Run("zero signing deadline stays zero", func(t *testing.T) {
entry := &uexecutortypes.PendingOutboundEntry{
OutboundId: "0xnone",
UniversalTxId: "utx-no-deadline",
CreatedAt: 1000,
}
outbound := &uexecutortypes.OutboundTx{
Id: "0xnone",
DestinationChain: "eip155:1",
Amount: "1",
}

result, err := convertOutboundToEvent(entry, outbound)
require.NoError(t, err)

var data uexecutortypes.OutboundCreatedEvent
require.NoError(t, json.Unmarshal(result.EventData, &data))
assert.Equal(t, int64(0), data.SigningDeadline)
})
}

func TestConvertFundMigrationEvent(t *testing.T) {
Expand Down Expand Up @@ -341,3 +353,32 @@ func TestConvertFundMigrationEvent(t *testing.T) {
assert.Equal(t, hashEventID(store.EventTypeSignFundMigrate, "42"), result.EventID)
})
}

func TestHashEventID(t *testing.T) {
t.Run("deterministic output", func(t *testing.T) {
id1 := hashEventID("keygen", "123")
id2 := hashEventID("keygen", "123")
assert.Equal(t, id1, id2)
})

t.Run("different types produce different IDs", func(t *testing.T) {
id1 := hashEventID("keygen", "123")
id2 := hashEventID("refresh", "123")
assert.NotEqual(t, id1, id2)
})

t.Run("different raw IDs produce different IDs", func(t *testing.T) {
id1 := hashEventID("keygen", "1")
id2 := hashEventID("keygen", "2")
assert.NotEqual(t, id1, id2)
})

t.Run("output is hex string of sha256 length", func(t *testing.T) {
id := hashEventID("type", "id")
assert.Len(t, id, 64) // sha256 = 32 bytes = 64 hex chars
})
}

func TestDefaultExpiryOffset(t *testing.T) {
assert.Equal(t, uint64(600), uint64(DefaultExpiryOffset))
}
38 changes: 37 additions & 1 deletion universalClient/chains/svm/rpc_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,40 @@ func (rc *RPCClient) GetLatestSlot(ctx context.Context) (uint64, error) {
return slot, err
}

// LatestFinalizedBlockTime returns the unix timestamp of the latest finalized
// block — the cluster's view of "now" against which on-chain deadline checks
// fire. Used by broadcaster and resolver to gate deadline-based decisions:
// comparing local wall-clock to this value catches host-clock skew, full
// cluster halts (block time stops advancing), and finalization stalls (the
// latest *finalized* block ages even while production continues).
//
// Returns 0 + nil error if block time is unavailable for the latest slot
// (e.g., the slot is too new for the RPC to have indexed). Returns 0 + err
// only when the slot lookup itself fails.
func (rc *RPCClient) LatestFinalizedBlockTime(ctx context.Context) (int64, error) {
slot, err := rc.GetLatestSlot(ctx)
if err != nil {
return 0, err
}

var blockTime int64
if err := rc.executeWithFailover(ctx, "get_block_time", func(client *rpc.Client) error {
t, innerErr := client.GetBlockTime(ctx, slot)
if innerErr != nil {
return innerErr
}
if t != nil {
blockTime = int64(*t)
}
return nil
}); err != nil {
// Block-time lookup failed (e.g., slot too recent). Surface 0 so caller
// treats it as "unknown freshness" and defers irreversible decisions.
return 0, nil
}
return blockTime, nil
}

// GetRecentBlockhash gets a recent blockhash for transaction building
func (rc *RPCClient) GetRecentBlockhash(ctx context.Context) (solana.Hash, error) {
var blockhash solana.Hash
Expand Down Expand Up @@ -320,7 +354,9 @@ func (rc *RPCClient) SimulateTransaction(ctx context.Context, tx *solana.Transac
return result.Value, nil
}

// GetAccountData fetches account data for a given public key
// GetAccountData fetches account data for a given public key. Uses Solana
// RPC's default commitment (`finalized`, per the JSON-RPC spec) — reorg-safe
// for both terminal decisions and race-recovery probes.
func (rc *RPCClient) GetAccountData(ctx context.Context, pubkey solana.PublicKey) ([]byte, error) {
var accountData []byte
err := rc.executeWithFailover(ctx, "get_account_data", func(client *rpc.Client) error {
Expand Down
Loading
Loading