diff --git a/app/simple/integration_test.go b/app/simple/integration_test.go index c8d7a852..e3b3f2c1 100644 --- a/app/simple/integration_test.go +++ b/app/simple/integration_test.go @@ -133,7 +133,7 @@ func TestSimpleAppIntegration(t *testing.T) { payloadBytes, err := json.Marshal(payload) require.NoError(t, err) - withdrawStateBytes, withdrawEvents, _, withdrawals, _, fuel, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, payloadBytes, depositState2Bytes, wasmBytes) + withdrawStateBytes, withdrawEvents, _, withdrawals, _, fuel, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), payloadBytes, depositState2Bytes, wasmBytes) require.Nil(t, err) require.NotNil(t, withdrawStateBytes) require.Len(t, withdrawEvents, 1) @@ -158,7 +158,7 @@ func TestSimpleAppIntegration(t *testing.T) { payloadBytes, err = json.Marshal(deanonPayload) require.NoError(t, err) - deanonStateBytes, _, _, _, reportBytes, fuel, failure := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Deanonymize, payloadBytes, withdrawStateBytes, wasmBytes) + deanonStateBytes, _, _, _, reportBytes, fuel, failure := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Deanonymize, uint64(0), payloadBytes, withdrawStateBytes, wasmBytes) require.Nil(t, failure) require.NotNil(t, deanonStateBytes) require.NotNil(t, reportBytes) @@ -183,7 +183,7 @@ func TestSimpleAppIntegration(t *testing.T) { payloadBytes, err = json.Marshal(payload) require.NoError(t, err) - compareStateBytes, events, _, withdrawals, _, fuel, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, payloadBytes, withdrawStateBytes, wasmBytes) + compareStateBytes, events, _, withdrawals, _, fuel, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), payloadBytes, withdrawStateBytes, wasmBytes) require.Nil(t, err) require.NotNil(t, compareStateBytes) require.Len(t, events, 1) @@ -227,7 +227,7 @@ func TestSimpleAppIntegration_NullPayload(t *testing.T) { t.Run("null payload json", func(t *testing.T) { nullPayload := []byte{} - _, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, nullPayload, initialStateBytes, wasmBytes) + _, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), nullPayload, initialStateBytes, wasmBytes) require.Nil(t, err) }) } @@ -268,7 +268,7 @@ func TestSimpleAppIntegration_NegativeScenarios(t *testing.T) { payloadBytes, err := json.Marshal(payload) require.NoError(t, err) - _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, payloadBytes, populatedStateBytes, wasmBytes) + _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), payloadBytes, populatedStateBytes, wasmBytes) require.Error(t, err) require.Contains(t, err.Error(), "Insufficient balance") }) @@ -286,7 +286,7 @@ func TestSimpleAppIntegration_NegativeScenarios(t *testing.T) { payloadBytes, err := json.Marshal(payload) require.NoError(t, err) - _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(nonExistentUser), common.Process, payloadBytes, populatedStateBytes, wasmBytes) + _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(nonExistentUser), common.Process, uint64(0), payloadBytes, populatedStateBytes, wasmBytes) require.Error(t, err) require.Contains(t, err.Error(), "does not exist") }) @@ -302,7 +302,7 @@ func TestSimpleAppIntegration_NegativeScenarios(t *testing.T) { payloadBytes, err := json.Marshal(payload) require.NoError(t, err) - _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(nonExistentUser), common.Process, payloadBytes, populatedStateBytes, wasmBytes) + _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(nonExistentUser), common.Process, uint64(0), payloadBytes, populatedStateBytes, wasmBytes) require.Error(t, err) require.Contains(t, err.Error(), "Account "+nonExistentUser.Hex()+" does not exist!") }) @@ -312,7 +312,7 @@ func TestSimpleAppIntegration_NegativeScenarios(t *testing.T) { payloadBytes, err := json.Marshal(payload) require.NoError(t, err) - _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, payloadBytes, populatedStateBytes, wasmBytes) + _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), payloadBytes, populatedStateBytes, wasmBytes) require.Error(t, err) require.Contains(t, err.Error(), "Withdraw instruction is missing") }) @@ -322,21 +322,21 @@ func TestSimpleAppIntegration_NegativeScenarios(t *testing.T) { payloadBytes, err := json.Marshal(payload) require.NoError(t, err) - _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, payloadBytes, populatedStateBytes, wasmBytes) + _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), payloadBytes, populatedStateBytes, wasmBytes) require.Error(t, err) require.Contains(t, err.Error(), "Compare instruction is missing") }) t.Run("unsupported instruction type", func(t *testing.T) { payload := `{"type":"invalid_instruction"}` - _, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, []byte(payload), populatedStateBytes, wasmBytes) + _, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), []byte(payload), populatedStateBytes, wasmBytes) require.Error(t, err) require.Contains(t, err.Error(), "Unsupported instruction type") }) t.Run("invalid payload json", func(t *testing.T) { payload := `{"type":"withdraw","withdraw":{"to":` - _, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, []byte(payload), populatedStateBytes, wasmBytes) + _, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), []byte(payload), populatedStateBytes, wasmBytes) require.Error(t, err) require.Contains(t, err.Error(), "Failed to parse payload instructions") }) @@ -372,7 +372,7 @@ func TestSimpleAppIntegration_NilData(t *testing.T) { payloadBytes, err := json.Marshal(deanonPayload) require.NoError(t, err) - _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Deanonymize, payloadBytes, nil, wasmBytes) + _, _, _, _, _, _, err = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Deanonymize, uint64(0), payloadBytes, nil, wasmBytes) require.Error(t, err) require.Contains(t, err.Error(), "Failed to parse application state") }) @@ -407,7 +407,7 @@ func TestSimpleAppIntegration_InvalidWasm(t *testing.T) { t.Run("process request with nil wasm", func(t *testing.T) { payload := []byte(`{"type":"withdraw","withdraw":{"to":"some_address","amount":100}}`) - _, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, payload, initialStateBytes, nil) + _, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), payload, initialStateBytes, nil) require.Error(t, err) require.Contains(t, err.Error(), "failed to load module") }) @@ -432,7 +432,7 @@ func TestSimpleAppIntegration_InvalidState(t *testing.T) { t.Run("process request with invalid state", func(t *testing.T) { payload := []byte(`{"type":"withdraw","withdraw":{"to":"some_address","amount":100}}`) - _, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, payload, invalidState, wasmBytes) + _, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), payload, invalidState, wasmBytes) require.Error(t, err) require.Contains(t, err.Error(), "Failed to parse application state") }) @@ -493,7 +493,7 @@ func TestSimpleAppIntegration_MemoryStress(t *testing.T) { require.NoError(t, ret, "failed to marshal withdraw payload at iteration %d", iterationIndex) runtimeMutex.Lock() - processStateBytes, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(userAddress), common.Process, withdrawPayloadBytes, stateBytes, wasmBytes) + processStateBytes, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(userAddress), common.Process, uint64(0), withdrawPayloadBytes, stateBytes, wasmBytes) require.Nil(t, err, "ProcessRequest failed at iteration %d", iterationIndex) stateBytes = processStateBytes runtimeMutex.Unlock() @@ -506,7 +506,7 @@ func TestSimpleAppIntegration_MemoryStress(t *testing.T) { deanonPayloadBytes, ret := json.Marshal(deanonPayload) require.NoError(t, ret, "failed to marshal deanonymize payload at iteration %d", iterationIndex) runtimeMutex.Lock() - deanonStateBytes, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(userAddress), common.Deanonymize, deanonPayloadBytes, stateBytes, wasmBytes) + deanonStateBytes, _, _, _, _, _, err := runtime.ProcessRequest(ctx, appId, ethCommon.Address(userAddress), common.Deanonymize, uint64(0), deanonPayloadBytes, stateBytes, wasmBytes) require.Nil(t, err, "Deanonymize ProcessRequest failed at iteration %d", iterationIndex) stateBytes = deanonStateBytes runtimeMutex.Unlock() @@ -573,7 +573,7 @@ func TestSimpleAppIntegration_MemoryCleanBetweenOps(t *testing.T) { payloadBytes, err := json.Marshal(payload) require.NoError(t, err) - stateBytes, _, _, _, _, _, failure2 := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, payloadBytes, stateBytes, wasmBytes) + stateBytes, _, _, _, _, _, failure2 := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), payloadBytes, stateBytes, wasmBytes) require.Nil(t, failure2) requireMemoryClean(t, runtime, wasmBytes, "memory leak after ProcessRequest (withdraw)") @@ -592,7 +592,7 @@ func TestSimpleAppIntegration_MemoryCleanBetweenOps(t *testing.T) { payloadBytes2, err := json.Marshal(payload2) require.NoError(t, err) - stateBytes, _, _, _, _, _, failure2 = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, payloadBytes2, stateBytes, wasmBytes) + stateBytes, _, _, _, _, _, failure2 = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), payloadBytes2, stateBytes, wasmBytes) require.Nil(t, failure2) requireMemoryClean(t, runtime, wasmBytes, "memory leak after ProcessRequest (compare)") @@ -604,7 +604,7 @@ func TestSimpleAppIntegration_MemoryCleanBetweenOps(t *testing.T) { payloadBytes, err = json.Marshal(deanonPayload) require.NoError(t, err) - _, _, _, _, _, _, failure = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Deanonymize, payloadBytes, stateBytes, wasmBytes) + _, _, _, _, _, _, failure = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Deanonymize, uint64(0), payloadBytes, stateBytes, wasmBytes) require.Nil(t, failure) requireMemoryClean(t, runtime, wasmBytes, "memory leak after GenerateDeanonymizationReport") @@ -639,12 +639,12 @@ func TestSimpleAppIntegration_ErrorPathMemory(t *testing.T) { payloadBytes, err := json.Marshal(payload) require.NoError(t, err) - _, _, _, _, _, _, failure2 := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, payloadBytes, stateBytes, wasmBytes) + _, _, _, _, _, _, failure2 := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), payloadBytes, stateBytes, wasmBytes) require.NotNil(t, failure2, "expected error for insufficient balance") requireMemoryClean(t, runtime, wasmBytes, "memory leak after error result from ProcessRequest") // Trigger error: non-existent account - _, _, _, _, _, _, failure2 = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user2Address), common.Process, payloadBytes, stateBytes, wasmBytes) + _, _, _, _, _, _, failure2 = runtime.ProcessRequest(ctx, appId, ethCommon.Address(user2Address), common.Process, uint64(0), payloadBytes, stateBytes, wasmBytes) require.NotNil(t, failure2, "expected error for non-existent account") requireMemoryClean(t, runtime, wasmBytes, "memory leak after error result for non-existent account") @@ -692,7 +692,7 @@ func TestSimpleAppIntegration_LargeResultRoundTrip(t *testing.T) { payloadBytes, err := json.Marshal(deanonPayload) require.NoError(t, err) - _, _, _, _, reportBytes, _, failure := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Deanonymize, payloadBytes, stateBytes, wasmBytes) + _, _, _, _, reportBytes, _, failure := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Deanonymize, uint64(0), payloadBytes, stateBytes, wasmBytes) // Generate report: SerializeAndWriteResult must handle the large report via BytesToPtr require.Nil(t, failure) require.NotNil(t, reportBytes) @@ -842,7 +842,7 @@ func TestERC20WithdrawalThroughWasm(t *testing.T) { payloadBytes, err := json.Marshal(payload) require.NoError(t, err) - newStateBytes, events, _, withdrawals, _, fuel, processFailure := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, payloadBytes, stateBytes, wasmBytes) + newStateBytes, events, _, withdrawals, _, fuel, processFailure := runtime.ProcessRequest(ctx, appId, ethCommon.Address(user1Address), common.Process, uint64(0), payloadBytes, stateBytes, wasmBytes) require.Nil(t, processFailure) require.NotNil(t, newStateBytes) require.Len(t, events, 1) diff --git a/app/simple/main.go b/app/simple/main.go index 37248b4d..258d218a 100644 --- a/app/simple/main.go +++ b/app/simple/main.go @@ -35,8 +35,9 @@ func deposit(appId int64, senderPtr *byte, senderLen int32, tokenPtr *byte, toke } //export process_request -func process_request(appId int64, senderPtr *byte, senderLen int32, requestType int32, payloadPtr *byte, payloadLen int32, statePtr *byte, stateLen int32) *byte { +func process_request(appId int64, senderPtr *byte, senderLen int32, requestType int32, blockTimestamp int64, payloadPtr *byte, payloadLen int32, statePtr *byte, stateLen int32) *byte { _ = appId + _ = blockTimestamp sender := types.PtrToAddress(senderPtr, senderLen) payloadJSON := utils.PtrToString(payloadPtr, payloadLen) stateJSON := utils.PtrToString(statePtr, stateLen) diff --git a/go.mod b/go.mod index 26a77328..5e81f3f2 100644 --- a/go.mod +++ b/go.mod @@ -132,3 +132,9 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +// Local replace for cross-repo development of the b-full dedup +// (SubtypeKeyMessage / DefaultSubtypeN / GenerateSubtype / AllSubtypes +// moved into vela-common-go/subtypes). Re-comment before any upstream PR +// per the [[preserve-local-replaces]] convention. +replace github.com/HorizenOfficial/vela-common-go => ../vela-common-go diff --git a/pkg/blockchain/testutil/sim_test_helper.go b/pkg/blockchain/testutil/sim_test_helper.go index 43220507..4a58ce4e 100644 --- a/pkg/blockchain/testutil/sim_test_helper.go +++ b/pkg/blockchain/testutil/sim_test_helper.go @@ -39,13 +39,19 @@ type SimTestHelper struct { tokenAllowlistInstance *bind.BoundContract TokenAllowlistAddress ethCommon.Address - // MockERC20 is populated by DeployMockERC20 and remains the zero value if - // the test never deploys one. Only one MockERC20 per SimTestHelper — tests - // that need multiple tokens should call DeployMockERC20 multiple times and - // track the returned addresses themselves. + // MockERC20Address is the most-recently-deployed MockERC20 (zero value if + // the test never deploys one). The single-token helpers (MintERC20 / + // ApproveERC20 / BalanceOfERC20) operate on it for backward compatibility. + // Multi-token tests call DeployMockERC20 more than once and use the + // address-parameterized ...At helpers, which resolve their binding from + // erc20Instances instead of this cached field. MockERC20Address ethCommon.Address mockERC20Contract *mockerc20.MockERC20 mockERC20Instance *bind.BoundContract + // erc20Instances holds one bound contract per deployed MockERC20, keyed by + // token address, so a single SimTestHelper can stage several distinct + // tokens at once. Populated by DeployMockERC20; read by the ...At helpers. + erc20Instances map[ethCommon.Address]*bind.BoundContract ProcessorContractAddress ethCommon.Address TeeSignerAddress ethCommon.Address @@ -384,12 +390,23 @@ func (s *SimTestHelper) GetRequestSubmittedEvent(tx *ethTypes.Transaction) *proc receipt, err := s.GetTxReceipt(tx) require.NoError(s.t, err, "error getting transaction receipt") - require.Equal(s.t, 1, len(receipt.Logs), "There should be one RequestSubmittedEvent") + require.NotEmpty(s.t, receipt.Logs, "transaction produced no logs") + + // An ETH request emits only the ProcessorEndpoint RequestSubmitted log, + // but an ERC-20 request also emits the token's Transfer log (from the + // contract's transferFrom pull), so the RequestSubmitted log is not + // necessarily the only one nor at index 0. Find it by trying to unpack + // each log against the RequestSubmitted event signature; UnpackLog + // returns an error for logs whose topic0 doesn't match. event := processorendpoint.ProcessorEndpointRequestSubmitted{} - err = s.processEndpointInstance.UnpackLog(&event, - processorendpoint.ProcessorEndpointRequestSubmittedEventName, *receipt.Logs[0]) - require.NoError(s.t, err, "error unpacking RequestSubmittedEvent") - return &event + for _, lg := range receipt.Logs { + if err := s.processEndpointInstance.UnpackLog(&event, + processorendpoint.ProcessorEndpointRequestSubmittedEventName, *lg); err == nil { + return &event + } + } + require.FailNow(s.t, "no RequestSubmitted log found in transaction receipt") + return nil } func (s *SimTestHelper) GetDeployRequestSubmittedEvent(tx *ethTypes.Transaction) *processorendpoint.ProcessorEndpointDeployRequestSubmitted { @@ -489,22 +506,15 @@ func (s *SimTestHelper) GetSimTeeAuthenticatorHelper() *SimTeeAuthenticatorHelpe // --- MockERC20 infrastructure --- // DeployMockERC20 deploys a MockERC20 token contract on the simulated chain -// and stores its binding on the helper. Subsequent MintERC20 / ApproveERC20 -// calls target this deployed instance. Returns the deployed token address. +// and stores its binding on the helper. Returns the deployed token address. // -// The mock's mint is permissionless and decimals are caller-chosen, so a -// single call is enough to stage any ERC-20 scenario. Tests needing multiple -// distinct tokens call this multiple times and track the returned addresses. +// The mock's mint is permissionless and decimals are caller-chosen. Calling +// this more than once stages several distinct tokens on one helper: each +// binding is kept in erc20Instances keyed by address, and the most recent +// deploy also becomes the cached single-token target (MockERC20Address). +// Single-token tests keep using MintERC20 / ApproveERC20 / BalanceOfERC20; +// multi-token tests use the address-parameterized ...At variants. func (s *SimTestHelper) DeployMockERC20(name, symbol string, decimals uint8) ethCommon.Address { - // A second call would silently overwrite the cached binding so that - // MintERC20 / ApproveERC20 / BalanceOfERC20 target only the latest token, - // breaking helpers for the previously deployed one. Fail fast instead. - // - // TODO: when a multi-token test legitimately hits this guard, drop the - // cached MockERC20Address / mockERC20Contract / mockERC20Instance fields - // and change the helpers to take a tokenAddress parameter, reconstructing - // the binding internally. Callers track the returned addresses themselves. - require.Nil(s.t, s.mockERC20Instance, "DeployMockERC20 already called") deployer := bind.DefaultDeployer(s.Deployer, s.sim.Client()) contract := mockerc20.NewMockERC20() deployParams := bind.DeploymentParams{ @@ -520,19 +530,42 @@ func (s *SimTestHelper) DeployMockERC20(name, symbol string, decimals uint8) eth _, err = bind.WaitDeployed(context.Background(), s.sim.Client(), tx.Hash()) require.NoError(s.t, err, "MockERC20 deployment not mined") + instance := contract.Instance(s.sim.Client(), addr) + if s.erc20Instances == nil { + s.erc20Instances = make(map[ethCommon.Address]*bind.BoundContract) + } + s.erc20Instances[addr] = instance + + // Cache the most-recent deploy as the single-token target for the + // non-parameterized helpers. mockERC20Contract is the shared ABI packer + // (address-independent), so one copy is enough for all tokens. s.MockERC20Address = addr s.mockERC20Contract = contract - s.mockERC20Instance = contract.Instance(s.sim.Client(), addr) + s.mockERC20Instance = instance return addr } -// MintERC20 mints `amount` tokens of the deployed MockERC20 to `to`. The -// mock's mint is permissionless; the Deployer account is used as msg.sender -// for consistency with other admin-style helpers. +// erc20InstanceFor returns the bound contract for a token deployed via +// DeployMockERC20, failing the test if the address was never deployed. +func (s *SimTestHelper) erc20InstanceFor(token ethCommon.Address) *bind.BoundContract { + inst, ok := s.erc20Instances[token] + require.True(s.t, ok, "MockERC20 %s was not deployed via DeployMockERC20", token.Hex()) + return inst +} + +// MintERC20 mints `amount` tokens of the most-recently-deployed MockERC20 to +// `to`. Convenience wrapper over MintERC20At for single-token tests. func (s *SimTestHelper) MintERC20(to ethCommon.Address, amount *big.Int) *ethTypes.Transaction { require.NotNil(s.t, s.mockERC20Instance, "DeployMockERC20 must be called before MintERC20") + return s.MintERC20At(s.MockERC20Address, to, amount) +} + +// MintERC20At mints `amount` tokens of the MockERC20 at `token` to `to`. The +// mock's mint is permissionless; the Deployer account is used as msg.sender +// for consistency with other admin-style helpers. +func (s *SimTestHelper) MintERC20At(token, to ethCommon.Address, amount *big.Int) *ethTypes.Transaction { tx, err := bind.Transact( - s.mockERC20Instance, + s.erc20InstanceFor(token), s.Deployer, s.mockERC20Contract.PackMint(to, amount), ) @@ -540,16 +573,22 @@ func (s *SimTestHelper) MintERC20(to ethCommon.Address, amount *big.Int) *ethTyp return tx } -// ApproveERC20 calls MockERC20.approve(spender, amount) from `owner`. The -// ProcessorEndpoint contract uses the resulting allowance to pull tokens via -// transferFrom when the user later calls submitRequest with an ERC-20 -// assetAmount. Tests call this to pre-approve before a deposit, since the -// wallet's submitRequest path uses transferFrom and does not embed an -// EIP-2612 permit. +// ApproveERC20 calls approve(spender, amount) on the most-recently-deployed +// MockERC20 from `owner`. Convenience wrapper over ApproveERC20At. func (s *SimTestHelper) ApproveERC20(owner *bind.TransactOpts, spender ethCommon.Address, amount *big.Int) *ethTypes.Transaction { require.NotNil(s.t, s.mockERC20Instance, "DeployMockERC20 must be called before ApproveERC20") + return s.ApproveERC20At(owner, s.MockERC20Address, spender, amount) +} + +// ApproveERC20At calls MockERC20.approve(spender, amount) from `owner` on the +// token at `token`. The ProcessorEndpoint contract uses the resulting +// allowance to pull tokens via transferFrom when the user later calls +// submitRequest with an ERC-20 assetAmount. Tests call this to pre-approve +// before a deposit, since the wallet's submitRequest path uses transferFrom +// and does not embed an EIP-2612 permit. +func (s *SimTestHelper) ApproveERC20At(owner *bind.TransactOpts, token, spender ethCommon.Address, amount *big.Int) *ethTypes.Transaction { tx, err := bind.Transact( - s.mockERC20Instance, + s.erc20InstanceFor(token), owner, s.mockERC20Contract.PackApprove(spender, amount), ) @@ -557,13 +596,19 @@ func (s *SimTestHelper) ApproveERC20(owner *bind.TransactOpts, spender ethCommon return tx } -// BalanceOfERC20 reads the caller-visible balance for `account` on the deployed -// MockERC20. Useful for end-to-end assertions (e.g. confirming that a -// ClaimPendingPayments moved tokens back to the user). +// BalanceOfERC20 reads the balance for `account` on the most-recently-deployed +// MockERC20. Convenience wrapper over BalanceOfERC20At. func (s *SimTestHelper) BalanceOfERC20(account ethCommon.Address) *big.Int { require.NotNil(s.t, s.mockERC20Instance, "DeployMockERC20 must be called before BalanceOfERC20") + return s.BalanceOfERC20At(s.MockERC20Address, account) +} + +// BalanceOfERC20At reads the caller-visible balance for `account` on the +// MockERC20 at `token`. Useful for end-to-end assertions (e.g. confirming a +// claim moved tokens to a sub-address). +func (s *SimTestHelper) BalanceOfERC20At(token, account ethCommon.Address) *big.Int { bal, err := bind.Call( - s.mockERC20Instance, + s.erc20InstanceFor(token), &bind.CallOpts{Pending: false}, s.mockERC20Contract.PackBalanceOf(account), s.mockERC20Contract.UnpackBalanceOf, diff --git a/pkg/common/config.go b/pkg/common/config.go index 7a6e3b41..a35388d5 100644 --- a/pkg/common/config.go +++ b/pkg/common/config.go @@ -32,7 +32,7 @@ func GetConfigVarInt64(name string, defaultValue int64, fileProperties *properti if confVar == "" { return defaultValue } else { - var parsed, err = strconv.ParseInt(confVar, 10, 32) + var parsed, err = strconv.ParseInt(confVar, 10, 64) if err != nil { fmt.Printf("Failed to convert %v for error %v, using default value\n", name, err) return defaultValue diff --git a/pkg/common/types.go b/pkg/common/types.go index 3ecd2884..bc7f51af 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -9,6 +9,7 @@ import ( "time" velacommon "github.com/HorizenOfficial/vela-common-go/common" + "github.com/HorizenOfficial/vela-common-go/wire" ethCommon "github.com/ethereum/go-ethereum/common" ) @@ -28,40 +29,19 @@ func NewApplicationId(id uint64) ApplicationIdType { return velacommon.NewApplicationId(id) } -// RequestType represents the type of request being sent to the TEE -type RequestType uint8 +// RequestType moved to the zero-dependency vela-common-go/wire leaf so TinyGo +// WASM guests can dispatch on it without this package's go-ethereum import +// closure; aliased here so existing call sites keep working. +type RequestType = wire.RequestType const ( - // Deploy represents a request to deploy a new application - Deploy RequestType = iota - // Process is used for processing a batch of requests - Process - // Deanonymize is used for deanonymization requests - Deanonymize - // AssociateKey records an association between an Ethereum address and a Secp521r1_PubKey - AssociateKey - // TrustProcess is analogous to Process but its Payload is sent in clear text - // (not encrypted toward the enclave communication key). - TrustProcess + Deploy = wire.Deploy + Process = wire.Process + Deanonymize = wire.Deanonymize + AssociateKey = wire.AssociateKey + TrustProcess = wire.TrustProcess ) -func (rt RequestType) String() string { - switch rt { - case Deploy: - return "deploy" - case Process: - return "process" - case Deanonymize: - return "deanonymize" - case AssociateKey: - return "associatekey" - case TrustProcess: - return "trustprocess" - default: - return "unknown" - } -} - // Request represents a request to the system type Request struct { // ProtocolVersion is the version of the protocol being used @@ -209,6 +189,11 @@ type PlainEvent struct { EventSubType [32]byte `json:"eventSubType"` // Data is the encrypted event data Data []byte `json:"data"` + // RecipientPubKey, when non-empty, overrides the standard keyStore[UserID] + // lookup in encryptEvents — the executor parses it as a P521 SEC1-uncompressed + // public key (133 bytes, prefix 0x04) and uses it as the ECIES receiver. + // When nil or empty, the keyStore lookup runs unchanged. + RecipientPubKey []byte `json:"recipientPubKey,omitempty"` } // AppEvent represents an application-level event (not encrypted, not user-directed). diff --git a/pkg/common/utils.go b/pkg/common/utils.go index 8700a8f0..62265194 100644 --- a/pkg/common/utils.go +++ b/pkg/common/utils.go @@ -8,22 +8,8 @@ import ( "runtime" ) -func (rt RequestType) ToUint8() (uint8, error) { - switch rt { - case Deploy: - return 0, nil - case Process: - return 1, nil - case Deanonymize: - return 2, nil - case AssociateKey: - return 3, nil - case TrustProcess: - return 4, nil - default: - return 0, fmt.Errorf("unknown RequestType: %s", rt) - } -} +// RequestType.ToUint8 moved to vela-common-go/wire with the type (methods +// cannot be defined on the aliased non-local type). func FnName() string { pc, _, _, _ := runtime.Caller(1) diff --git a/pkg/executor/encrypt_events_test.go b/pkg/executor/encrypt_events_test.go index d3fb0a8b..de8f96e8 100644 --- a/pkg/executor/encrypt_events_test.go +++ b/pkg/executor/encrypt_events_test.go @@ -7,6 +7,7 @@ import ( ethCommon "github.com/ethereum/go-ethereum/common" "github.com/HorizenOfficial/vela/pkg/common" "github.com/HorizenOfficial/vela/pkg/common/appdata" + "github.com/HorizenOfficial/vela/pkg/common/apperrors" "github.com/HorizenOfficial/vela/pkg/crypto" "github.com/stretchr/testify/require" ) @@ -37,3 +38,116 @@ func TestEncryptEventsCopiesSubType(t *testing.T) { require.Equal(t, userAddr, encrypted[0].UserID) require.NotEmpty(t, encrypted[0].EncryptedData) } + +// When PlainEvent.RecipientPubKey is set, encryptEvents must use it as the +// ECIES receiver and ignore keyStore[UserID] entirely. The encrypted payload +// must round-trip with the *explicit* recipient's private key — proving the +// explicit-recipient path was taken, not the keyStore fallback. +func TestEncryptEvents_ExplicitRecipientPubKey_UsedAndRoundTrips(t *testing.T) { + executorKey, err := crypto.GeneratePrivateKeyP521() + require.NoError(t, err) + + // Explicit recipient — NOT in keyStore. + explicitKey, err := crypto.GeneratePrivateKeyP521() + require.NoError(t, err) + + // A different key registered in keyStore for the same UserID — to prove + // the explicit path is taken, not the keyStore fallback. If the fallback + // fired, the ciphertext would only decrypt with this keyStore key. + wrongKey, err := crypto.GeneratePrivateKeyP521() + require.NoError(t, err) + + userAddr := ethCommon.HexToAddress("0xadd0000000000000000000000000000000000002") + keyStore := appdata.KeyStore{ + userAddr: wrongKey.PublicKey(), + } + + plaintext := []byte("hint-for-scheduler") + plain := []common.PlainEvent{{ + UserID: userAddr, + EventSubType: [32]byte{0xCC}, + Data: plaintext, + RecipientPubKey: explicitKey.PublicKey().Bytes(), + }} + + executor := &StatelessExecutor{log: testLogger} + encrypted, failure, err := executor.encryptEvents(context.Background(), plain, common.NewApplicationId(1), executorKey, nil, keyStore, appdata.SeedStore{}) + require.Nil(t, failure) + require.Nil(t, err) + require.Len(t, encrypted, 1) + require.NotEmpty(t, encrypted[0].EncryptedData) + + // Decrypt with the explicit recipient's private key — must succeed. + decrypted, err := crypto.Decrypt(executorKey.PublicKey(), explicitKey, encrypted[0].EncryptedData) + require.NoError(t, err) + require.Equal(t, plaintext, decrypted) + + // Decrypt with the keyStore-registered (wrong) key — must fail. This proves + // the keyStore fallback was NOT taken. + _, err = crypto.Decrypt(executorKey.PublicKey(), wrongKey, encrypted[0].EncryptedData) + require.Error(t, err) +} + +// A non-empty but malformed RecipientPubKey must surface as a typed +// CodeParsingKeyError failure, not silently fall back to keyStore. +func TestEncryptEvents_InvalidRecipientPubKey_ReturnsParsingKeyError(t *testing.T) { + executorKey, err := crypto.GeneratePrivateKeyP521() + require.NoError(t, err) + + userAddr := ethCommon.HexToAddress("0xadd0000000000000000000000000000000000003") + // keyStore has a valid entry for this UserID — but we should NOT fall back + // to it; a malformed explicit key must be treated as an error. + validKey, err := crypto.GeneratePrivateKeyP521() + require.NoError(t, err) + keyStore := appdata.KeyStore{ + userAddr: validKey.PublicKey(), + } + + plain := []common.PlainEvent{{ + UserID: userAddr, + EventSubType: [32]byte{0xDD}, + Data: []byte("payload"), + RecipientPubKey: []byte{0x04, 0xDE, 0xAD, 0xBE, 0xEF}, // wrong length, definitely not a valid P521 SEC1 point + }} + + executor := &StatelessExecutor{log: testLogger} + encrypted, failure, err := executor.encryptEvents(context.Background(), plain, common.NewApplicationId(1), executorKey, nil, keyStore, appdata.SeedStore{}) + require.Nil(t, err) + require.Nil(t, encrypted) + require.NotNil(t, failure) + require.Equal(t, apperrors.CodeParsingKeyError.Code, failure.RequestError.Code) +} + +// When RecipientPubKey is nil/empty, the existing keyStore[UserID] lookup must +// run unchanged — regression coverage for the vela-nova flow. +func TestEncryptEvents_NilRecipientPubKey_FallsBackToKeyStore(t *testing.T) { + executorKey, err := crypto.GeneratePrivateKeyP521() + require.NoError(t, err) + + userKey, err := crypto.GeneratePrivateKeyP521() + require.NoError(t, err) + + userAddr := ethCommon.HexToAddress("0xadd0000000000000000000000000000000000004") + keyStore := appdata.KeyStore{ + userAddr: userKey.PublicKey(), + } + + plaintext := []byte("vela-nova-style-event") + plain := []common.PlainEvent{{ + UserID: userAddr, + EventSubType: [32]byte{0xEE}, + Data: plaintext, + // RecipientPubKey intentionally unset (nil). + }} + + executor := &StatelessExecutor{log: testLogger} + encrypted, failure, err := executor.encryptEvents(context.Background(), plain, common.NewApplicationId(1), executorKey, nil, keyStore, appdata.SeedStore{}) + require.Nil(t, failure) + require.Nil(t, err) + require.Len(t, encrypted, 1) + + // The encrypted payload must decrypt with the keyStore-registered user key. + decrypted, err := crypto.Decrypt(executorKey.PublicKey(), userKey, encrypted[0].EncryptedData) + require.NoError(t, err) + require.Equal(t, plaintext, decrypted) +} diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index 44f801f6..dbd39d2c 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -12,6 +12,7 @@ import ( ethCommon "github.com/ethereum/go-ethereum/common" "github.com/hf/nsm" + "github.com/HorizenOfficial/vela-common-go/subtypes" "github.com/HorizenOfficial/vela/pkg/admin" "github.com/HorizenOfficial/vela/pkg/common" "github.com/HorizenOfficial/vela/pkg/common/appdata" @@ -677,8 +678,20 @@ func (e *StatelessExecutor) HandleProcessRequest(ctx context.Context, req *commo wasmPayload = decryptedPayload } + // Source the chain-attested block.timestamp from req.Timestamp and pass it to the runtime. + // Request.Validate() has already constrained the value to a non-negative bigint; here we + // additionally require it to fit in uint64 before crossing the WASM boundary. + tsBig := req.Timestamp.ToInt() + if !tsBig.IsUint64() { + errorPayload, err := e.processErrorResponse(req, + appState.StateRoot, + apperrors.New(apperrors.CodeInternalFallback, fmt.Sprintf("block timestamp does not fit in uint64: %s", tsBig.String()))) + return errorPayload, nil, nil, err + } + blockTimestamp := tsBig.Uint64() + // Invoke WASM method to process the request - newState, reqEvents, reqAppEvents, reqWithdrawals, reqReportData, reqFuel, failure := e.runtime.ProcessRequest(ctx, req.ApplicationID, req.Sender, req.RequestType, wasmPayload, tempState, wasmModule) + newState, reqEvents, reqAppEvents, reqWithdrawals, reqReportData, reqFuel, failure := e.runtime.ProcessRequest(ctx, req.ApplicationID, req.Sender, req.RequestType, blockTimestamp, wasmPayload, tempState, wasmModule) if failure != nil { errorPayload, err := e.processErrorResponse(req, appState.StateRoot, @@ -1105,11 +1118,23 @@ func (e *StatelessExecutor) encryptEvents(ctx context.Context, events []common.P encryptedEvents := make([]common.Event, len(events)) for i, event := range events { - // retrieve user Secp521r1_PubKey - userKey, exists := keyStore[event.UserID] - - if !exists { - return nil, apperrors.New(apperrors.CodePubKeyNotRegistered, "no Secp521r1_PubKey found"), nil + // Resolve the ECIES recipient pubkey: prefer the WASM-supplied + // RecipientPubKey when set, fall back to keyStore[event.UserID] otherwise. + // Used by vela-ned for hint events whose recipient (the Scheduler) does not + // register a key via ASSOCIATEKEY and is therefore absent from keyStore. + var userKey *cryptotypes.PublicKeyP521 + if len(event.RecipientPubKey) > 0 { + parsed, perr := cryptotypes.NewPublicKeyP521(event.RecipientPubKey) + if perr != nil { + return nil, apperrors.New(apperrors.CodeParsingKeyError, fmt.Sprintf("invalid RecipientPubKey: %v", perr)), nil + } + userKey = parsed + } else { + var exists bool + userKey, exists = keyStore[event.UserID] + if !exists { + return nil, apperrors.New(apperrors.CodePubKeyNotRegistered, "no Secp521r1_PubKey found"), nil + } } // Encrypt the event data encryptedData, err := crypto.Encrypt(key, userKey, event.Data) @@ -1121,7 +1146,7 @@ func (e *StatelessExecutor) encryptEvents(ctx context.Context, events []common.P // otherwise fall back to the WASM-provided subtype. eventSubType := event.EventSubType if seed, hasSeed := seedStore[event.UserID]; hasSeed { - privSubtype, err := GenerateRandomSubtype(seed, DefaultSubtypeN) + privSubtype, err := GenerateRandomSubtype(seed, subtypes.DefaultSubtypeN) if err != nil { e.log.Warn("Executor: failed to generate random subtype for user %s, falling back to WASM subtype: %v", event.UserID, err) } else { diff --git a/pkg/executor/handle_deploy_app_test.go b/pkg/executor/handle_deploy_app_test.go index b656d415..c0be3a8d 100644 --- a/pkg/executor/handle_deploy_app_test.go +++ b/pkg/executor/handle_deploy_app_test.go @@ -181,7 +181,7 @@ func (r *failingRuntime) Deposit(_ context.Context, _ common.ApplicationIdType, return nil, nil, nil, big.NewInt(0), nil } -func (r *failingRuntime) ProcessRequest(_ context.Context, _ common.ApplicationIdType, _ ethCommon.Address, _ common.RequestType, _ []byte, _ []byte, _ []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { +func (r *failingRuntime) ProcessRequest(_ context.Context, _ common.ApplicationIdType, _ ethCommon.Address, _ common.RequestType, _ uint64, _ []byte, _ []byte, _ []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { return nil, nil, nil, nil, nil, big.NewInt(0), nil } diff --git a/pkg/executor/handle_process_request_test.go b/pkg/executor/handle_process_request_test.go index 295b8579..53b3d623 100644 --- a/pkg/executor/handle_process_request_test.go +++ b/pkg/executor/handle_process_request_test.go @@ -10,6 +10,7 @@ import ( "testing" ethCrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/HorizenOfficial/vela-common-go/subtypes" "github.com/HorizenOfficial/vela/pkg/common" "github.com/HorizenOfficial/vela/pkg/common/appdata" "github.com/HorizenOfficial/vela/pkg/common/apperrors" @@ -28,6 +29,7 @@ func newProcessRequest() *common.Request { RequestID: commontestutil.GenerateRandomRequestID(), RequestType: common.Process, Payload: nil, // set per test + Timestamp: common.NewBig(0), MaxFeeValue: common.NewBig(1000), AssetAmount: common.NewBig(0), } @@ -186,9 +188,9 @@ func (r *countingRuntime) Deposit(ctx context.Context, appId common.ApplicationI return r.MockRuntime.Deposit(ctx, appId, sender, tokenAddress, depositAmount, state, wasm) } -func (r *countingRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { +func (r *countingRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, blockTimestamp uint64, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { r.processCalls++ - return r.MockRuntime.ProcessRequest(ctx, appId, sender, requestType, payload, state, wasm) + return r.MockRuntime.ProcessRequest(ctx, appId, sender, requestType, blockTimestamp, payload, state, wasm) } func TestHandleProcessRequest_EmptyWasmModule(t *testing.T) { @@ -407,7 +409,7 @@ type failingProcessRuntime struct { MockRuntime } -func (r *failingProcessRuntime) ProcessRequest(_ context.Context, _ common.ApplicationIdType, _ ethCommon.Address, _ common.RequestType, _ []byte, _ []byte, _ []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { +func (r *failingProcessRuntime) ProcessRequest(_ context.Context, _ common.ApplicationIdType, _ ethCommon.Address, _ common.RequestType, _ uint64, _ []byte, _ []byte, _ []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { return nil, nil, nil, nil, nil, big.NewInt(10), apperrors.New(apperrors.CodeRequestFuncFailed, "wasm execution failed") } @@ -441,8 +443,8 @@ type reportOnProcessRuntime struct { MockRuntime } -func (r *reportOnProcessRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { - newState, events, appEvents, withdrawals, _, fuel, failure := r.MockRuntime.ProcessRequest(ctx, appId, sender, requestType, payload, state, wasm) +func (r *reportOnProcessRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, blockTimestamp uint64, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { + newState, events, appEvents, withdrawals, _, fuel, failure := r.MockRuntime.ProcessRequest(ctx, appId, sender, requestType, blockTimestamp, payload, state, wasm) // Always return a report, even for non-Deanonymize requests return newState, events, appEvents, withdrawals, []byte(`{"unexpected":"report"}`), fuel, failure } @@ -477,8 +479,8 @@ type noReportDeanonymizeRuntime struct { MockRuntime } -func (r *noReportDeanonymizeRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { - newState, events, appEvents, withdrawals, _, fuel, failure := r.MockRuntime.ProcessRequest(ctx, appId, sender, requestType, payload, state, wasm) +func (r *noReportDeanonymizeRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, blockTimestamp uint64, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { + newState, events, appEvents, withdrawals, _, fuel, failure := r.MockRuntime.ProcessRequest(ctx, appId, sender, requestType, blockTimestamp, payload, state, wasm) // Force empty report even for Deanonymize return newState, events, appEvents, withdrawals, nil, fuel, failure } @@ -512,8 +514,8 @@ type expensiveProcessRuntime struct { MockRuntime } -func (r *expensiveProcessRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { - newState, events, appEvents, withdrawals, report, _, failure := r.MockRuntime.ProcessRequest(ctx, appId, sender, requestType, payload, state, wasm) +func (r *expensiveProcessRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, blockTimestamp uint64, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { + newState, events, appEvents, withdrawals, report, _, failure := r.MockRuntime.ProcessRequest(ctx, appId, sender, requestType, blockTimestamp, payload, state, wasm) return newState, events, appEvents, withdrawals, report, big.NewInt(999999), failure } @@ -558,7 +560,7 @@ func TestHandleProcessRequest_AssociateKey_Success(t *testing.T) { sender := ethCommon.HexToAddress(signingKey.PublicKey().Address()) // Compute seed: sign keccak256(SubtypeKeyMessage) with the secp256k1 key - msgHash := ethCrypto.Keccak256([]byte(SubtypeKeyMessage)) + msgHash := ethCrypto.Keccak256([]byte(subtypes.SubtypeKeyMessage)) seed, err := signingKey.Sign(msgHash) require.NoError(t, err) require.Len(t, seed, 65) @@ -717,10 +719,10 @@ type payloadCapturingRuntime struct { seenType common.RequestType } -func (r *payloadCapturingRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { +func (r *payloadCapturingRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, blockTimestamp uint64, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { r.seenPayload = append([]byte(nil), payload...) r.seenType = requestType - return r.MockRuntime.ProcessRequest(ctx, appId, sender, requestType, payload, state, wasm) + return r.MockRuntime.ProcessRequest(ctx, appId, sender, requestType, blockTimestamp, payload, state, wasm) } // TestHandleProcessRequest_TrustProcess_PayloadForwardedAsIs verifies that TrustProcess diff --git a/pkg/executor/interface.go b/pkg/executor/interface.go index d66ac3d6..7b627f1c 100644 --- a/pkg/executor/interface.go +++ b/pkg/executor/interface.go @@ -39,8 +39,9 @@ type Runtime interface { Deploy(ctx context.Context, appId common.ApplicationIdType, constructorParams []byte, wasm []byte) ([]byte, *big.Int, error) // Deposit processes a deposit with token awareness (tokenAddress = 0x0 for ETH) Deposit(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, tokenAddress ethCommon.Address, depositAmount *big.Int, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, *big.Int, *apperrors.RequestFailure) - // ProcessRequest processes a request and returns the new state, events, withdrawals, and optionally a deanonymization report - ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) + // ProcessRequest processes a request and returns the new state, events, withdrawals, and optionally a deanonymization report. + // blockTimestamp is the chain-attested block.timestamp at request enqueue (sourced from Request.Timestamp). + ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, blockTimestamp uint64, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) // Close closes the WASM runtime Close() error } diff --git a/pkg/executor/mock_runtime.go b/pkg/executor/mock_runtime.go index debd0ee0..f90e5cf6 100644 --- a/pkg/executor/mock_runtime.go +++ b/pkg/executor/mock_runtime.go @@ -148,7 +148,7 @@ func (r *MockRuntime) Deposit(ctx context.Context, appId common.ApplicationIdTyp } // ProcessRequest processes a request and returns the new state, events, withdrawals, and optionally a deanonymization report -func (r *MockRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { +func (r *MockRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, blockTimestamp uint64, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { r.log.Info("Mock Runtime: Processing request for application %d (type: %s, payload size: %d, state size: %d)", appId, requestType, len(payload), len(state)) var currentState testApplicationInternalState diff --git a/pkg/executor/mock_runtime_test.go b/pkg/executor/mock_runtime_test.go index 816762dd..c9d0f156 100644 --- a/pkg/executor/mock_runtime_test.go +++ b/pkg/executor/mock_runtime_test.go @@ -172,7 +172,7 @@ func TestMockRuntime_ProcessRequest_Transfer(t *testing.T) { t.Fatalf("Failed to marshal transfer instructions: %v", err) } - newState, events, _, withdrawals, _, fuel, failure := runtime.ProcessRequest(ctx, appId, sender, common.Process, payload, serializedState, wasmBytes) + newState, events, _, withdrawals, _, fuel, failure := runtime.ProcessRequest(ctx, appId, sender, common.Process, uint64(0), payload, serializedState, wasmBytes) if failure != nil { t.Fatalf("Transfer failed: %v", failure) } @@ -259,7 +259,7 @@ func TestMockRuntime_ProcessRequest_Withdrawal(t *testing.T) { t.Fatalf("Failed to marshal withdraw instructions: %v", err) } - newState, events, _, withdrawals, _, fuel, failure := runtime.ProcessRequest(ctx, appId, sender, common.Process, payload, serializedState, wasmBytes) + newState, events, _, withdrawals, _, fuel, failure := runtime.ProcessRequest(ctx, appId, sender, common.Process, uint64(0), payload, serializedState, wasmBytes) if failure != nil { t.Fatalf("Withdrawal failed: %v", failure) } @@ -343,7 +343,7 @@ func TestMockRuntime_ProcessRequest_InsufficientBalance(t *testing.T) { } ctx := context.Background() - _, _, _, _, _, _, failure := runtime.ProcessRequest(ctx, appId, sender, common.Process, payload, serializedState, wasmBytes) + _, _, _, _, _, _, failure := runtime.ProcessRequest(ctx, appId, sender, common.Process, uint64(0), payload, serializedState, wasmBytes) if failure == nil { t.Error("Expected error for insufficient balance, got nil") } @@ -394,7 +394,7 @@ func TestMockRuntime_DeanonymizationViaProcessRequest(t *testing.T) { t.Fatalf("Failed to marshal deanonymize payload: %v", err) } - _, _, _, _, reportBytes, _, failure := runtime.ProcessRequest(context.Background(), appId, sender1, common.Deanonymize, payload, serializedState, wasmBytes) + _, _, _, _, reportBytes, _, failure := runtime.ProcessRequest(context.Background(), appId, sender1, common.Deanonymize, uint64(0), payload, serializedState, wasmBytes) if failure != nil { t.Fatalf("Deanonymize ProcessRequest failed: %v", failure) } diff --git a/pkg/executor/subtype.go b/pkg/executor/subtype.go index c20d3337..8a1031b6 100644 --- a/pkg/executor/subtype.go +++ b/pkg/executor/subtype.go @@ -1,48 +1,28 @@ package executor import ( - "crypto/hmac" "crypto/rand" - "crypto/sha256" "fmt" "math/big" -) - -// SubtypeKeyMessage is the message used to derive a seed. -// Change this string to rotate all user subtype sets. -const SubtypeKeyMessage = "subtype-key-v1" -// DefaultSubtypeN is the number of possible subtypes per user (anonymity set size). -const DefaultSubtypeN = 50 - -// GenerateSubtype returns HMAC-SHA256(key=seed, data=[]byte{index}) as a 32-byte -// value, matching the on-chain bytes32 event subtype. -// index should be in the range [1, N]. -func GenerateSubtype(seed []byte, index int) [32]byte { - mac := hmac.New(sha256.New, seed) - mac.Write([]byte{byte(index)}) - var out [32]byte - copy(out[:], mac.Sum(nil)) - return out -} + "github.com/HorizenOfficial/vela-common-go/subtypes" +) -// AllSubtypes returns GenerateSubtype(seed, i) for i in 1..n. -// The returned slice has length n; index i maps to result[i-1]. -func AllSubtypes(seed []byte, n int) [][32]byte { - result := make([][32]byte, n) - for i := 1; i <= n; i++ { - result[i-1] = GenerateSubtype(seed, i) - } - return result -} +// The seed-derivation constant, the anonymity-set size, and the deterministic +// subtype-set generator (GenerateSubtype / AllSubtypes) live in vela-common-go +// to deduplicate the same definitions across vela, vela-nova, and vela-ned. +// This file keeps only GenerateRandomSubtype because it depends on crypto/rand, +// which is host-only — the deterministic primitives belong in the shared +// package; the random pick lives next to its caller (encryptEvents). // GenerateRandomSubtype picks a cryptographically random index in [1, n] -// and returns GenerateSubtype(seed, index). +// and returns subtypes.GenerateSubtype(seed, index). Used by encryptEvents +// to rotate outbound events' EventSubType across the per-user anonymity set. func GenerateRandomSubtype(seed []byte, n int) ([32]byte, error) { randVal, err := rand.Int(rand.Reader, big.NewInt(int64(n))) if err != nil { return [32]byte{}, fmt.Errorf("failed to generate random index: %w", err) } index := int(randVal.Int64()) + 1 // shift [0, n) → [1, n] - return GenerateSubtype(seed, index), nil + return subtypes.GenerateSubtype(seed, index), nil } diff --git a/pkg/executor/subtype_test.go b/pkg/executor/subtype_test.go index 2769a2e3..ed594d79 100644 --- a/pkg/executor/subtype_test.go +++ b/pkg/executor/subtype_test.go @@ -6,6 +6,7 @@ import ( "testing" ethCrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/HorizenOfficial/vela-common-go/subtypes" "github.com/HorizenOfficial/vela/pkg/common" "github.com/HorizenOfficial/vela/pkg/common/appdata" commontestutil "github.com/HorizenOfficial/vela/pkg/common/testutil" @@ -17,36 +18,36 @@ import ( func TestGenerateSubtype_Deterministic(t *testing.T) { seed := mustSeed(t) - s1 := GenerateSubtype(seed, 1) - s2 := GenerateSubtype(seed, 1) + s1 := subtypes.GenerateSubtype(seed, 1) + s2 := subtypes.GenerateSubtype(seed, 1) require.Equal(t, s1, s2) } func TestGenerateSubtype_DifferentIndex(t *testing.T) { seed := mustSeed(t) - require.NotEqual(t, GenerateSubtype(seed, 1), GenerateSubtype(seed, 2)) + require.NotEqual(t, subtypes.GenerateSubtype(seed, 1), subtypes.GenerateSubtype(seed, 2)) } func TestGenerateSubtype_DifferentSeed(t *testing.T) { seed1, seed2 := mustSeed(t), mustSeed(t) - require.NotEqual(t, GenerateSubtype(seed1, 1), GenerateSubtype(seed2, 1)) + require.NotEqual(t, subtypes.GenerateSubtype(seed1, 1), subtypes.GenerateSubtype(seed2, 1)) } func TestGenerateSubtype_Format(t *testing.T) { - s := GenerateSubtype(mustSeed(t), 1) + s := subtypes.GenerateSubtype(mustSeed(t), 1) require.NotEqual(t, [32]byte{}, s, "should not be all zeros") } // --- AllSubtypes tests --- func TestAllSubtypes_Length(t *testing.T) { - require.Len(t, AllSubtypes(mustSeed(t), 50), 50) + require.Len(t, subtypes.AllSubtypes(mustSeed(t), 50), 50) } func TestAllSubtypes_Unique(t *testing.T) { - subtypes := AllSubtypes(mustSeed(t), 50) + subs := subtypes.AllSubtypes(mustSeed(t), 50) seen := make(map[[32]byte]struct{}, 50) - for _, st := range subtypes { + for _, st := range subs { _, dup := seen[st] require.False(t, dup, "duplicate subtype found: 0x%x", st) seen[st] = struct{}{} @@ -55,23 +56,23 @@ func TestAllSubtypes_Unique(t *testing.T) { func TestAllSubtypes_Indices(t *testing.T) { seed := mustSeed(t) - subtypes := AllSubtypes(seed, 3) - require.Equal(t, GenerateSubtype(seed, 1), subtypes[0]) - require.Equal(t, GenerateSubtype(seed, 2), subtypes[1]) - require.Equal(t, GenerateSubtype(seed, 3), subtypes[2]) + subs := subtypes.AllSubtypes(seed, 3) + require.Equal(t, subtypes.GenerateSubtype(seed, 1), subs[0]) + require.Equal(t, subtypes.GenerateSubtype(seed, 2), subs[1]) + require.Equal(t, subtypes.GenerateSubtype(seed, 3), subs[2]) } // --- GenerateRandomSubtype tests --- func TestGenerateRandomSubtype_InSet(t *testing.T) { seed := mustSeed(t) - all := AllSubtypes(seed, DefaultSubtypeN) + all := subtypes.AllSubtypes(seed, subtypes.DefaultSubtypeN) allSet := make(map[[32]byte]struct{}, len(all)) for _, st := range all { allSet[st] = struct{}{} } for i := 0; i < 20; i++ { - st, err := GenerateRandomSubtype(seed, DefaultSubtypeN) + st, err := GenerateRandomSubtype(seed, subtypes.DefaultSubtypeN) require.NoError(t, err) _, ok := allSet[st] require.True(t, ok, "generated subtype 0x%x not in expected set", st) @@ -91,8 +92,8 @@ func TestAssociateKey_AndSubtypeGeneration(t *testing.T) { require.NoError(t, err) sender := ethCrypto.PubkeyToAddress(signingKey.PublicKey) - // Compute seed: sign keccak256(SubtypeKeyMessage) deterministically - msgHash := ethCrypto.Keccak256([]byte(SubtypeKeyMessage)) + // Compute seed: sign keccak256(subtypes.SubtypeKeyMessage) deterministically + msgHash := ethCrypto.Keccak256([]byte(subtypes.SubtypeKeyMessage)) seed, err := ethCrypto.Sign(msgHash, signingKey) require.NoError(t, err) require.Len(t, seed, 65) @@ -166,7 +167,7 @@ func TestAssociateKey_AndSubtypeGeneration(t *testing.T) { // The subtype should be one of the N possible subtypes derived from the seed generatedSubtype := encryptedEvents[0].EventSubType - allSubtypes := AllSubtypes(seed, DefaultSubtypeN) + allSubtypes := subtypes.AllSubtypes(seed, subtypes.DefaultSubtypeN) require.Contains(t, allSubtypes, generatedSubtype, "generated subtype should be in the expected set") require.NotEqual(t, wasmSubtype, generatedSubtype, "WASM-provided subtype should be overridden") } @@ -217,8 +218,8 @@ func TestEventRetrieval_BySubtypeSet(t *testing.T) { require.NoError(t, err) require.Len(t, encrypted, 3) - // All user A events should have a subtype in AllSubtypes(seedA, N) - userASubtypes := AllSubtypes(seedA, DefaultSubtypeN) + // All user A events should have a subtype in subtypes.AllSubtypes(seedA, N) + userASubtypes := subtypes.AllSubtypes(seedA, subtypes.DefaultSubtypeN) subtypeSet := make(map[[32]byte]struct{}, len(userASubtypes)) for _, st := range userASubtypes { subtypeSet[st] = struct{}{} @@ -250,7 +251,7 @@ func mustSeed(t *testing.T) []byte { t.Helper() k, err := ethCrypto.GenerateKey() require.NoError(t, err) - msgHash := ethCrypto.Keccak256([]byte(SubtypeKeyMessage)) + msgHash := ethCrypto.Keccak256([]byte(subtypes.SubtypeKeyMessage)) seed, err := ethCrypto.Sign(msgHash, k) require.NoError(t, err) return seed diff --git a/pkg/testutil/crypto_helpers.go b/pkg/testutil/crypto_helpers.go index 5af3551c..11d0766d 100644 --- a/pkg/testutil/crypto_helpers.go +++ b/pkg/testutil/crypto_helpers.go @@ -11,6 +11,7 @@ import ( ethCrypto "github.com/ethereum/go-ethereum/crypto" velacommon "github.com/HorizenOfficial/vela-common-go/common" + "github.com/HorizenOfficial/vela-common-go/subtypes" "github.com/HorizenOfficial/vela/pkg/common" cryptotypes "github.com/HorizenOfficial/vela/pkg/common/crypto" "github.com/HorizenOfficial/vela/pkg/crypto" @@ -100,7 +101,7 @@ func (c *CryptoHelper) ComputeSeed(userID ethCommon.Address) ([]byte, error) { if err != nil { return nil, err } - msgHash := ethCrypto.Keccak256([]byte(executor.SubtypeKeyMessage)) + msgHash := ethCrypto.Keccak256([]byte(subtypes.SubtypeKeyMessage)) seed, err := signingKey.Sign(msgHash) if err != nil { return nil, fmt.Errorf("failed to sign subtype message for user %s: %w", userID, err) diff --git a/pkg/wasm/cgo_flags_linux.go b/pkg/wasm/cgo_flags_linux.go new file mode 100644 index 00000000..119a13b0 --- /dev/null +++ b/pkg/wasm/cgo_flags_linux.go @@ -0,0 +1,29 @@ +// Force the GNU linker to mark the final binary's PT_GNU_STACK as +// non-executable even though wasmtime-go's bundled libwasmtime.a ships an +// assembly object (x86_64.o — the fiber-switching trampolines) that omits +// the `.note.GNU-stack` section. Without this flag GNU ld emits +// +// /usr/bin/ld: warning: x86_64.o: missing .note.GNU-stack section +// implies executable stack +// /usr/bin/ld: NOTE: This behaviour is deprecated and will be removed in +// a future version of the linker +// +// on every link of a binary that pulls in pkg/wasm. The flag is link-time +// only — it sets the PT_GNU_STACK program-header bits in the ELF output +// and has no runtime effect. +// +// Linux-only build tag because the warning and the `-z noexecstack` option +// are specific to GNU ld. macOS (ld64) and Windows (link.exe) neither emit +// the warning nor recognize the option. +// +// REMOVE-WHEN: wasmtime-go is upgraded to >= v42 (audited 2026-06-30). +// In v44.0.0's bundled libwasmtime.a, every one of the 360 archive members +// carries `.note.GNU-stack` — the offending v1.0.0 `x86_64.o` is gone, so +// the warning no longer fires and this file becomes dead weight. + +//go:build linux + +package wasm + +// #cgo LDFLAGS: -Wl,-z,noexecstack +import "C" diff --git a/pkg/wasm/host_crypto.go b/pkg/wasm/host_crypto.go new file mode 100644 index 00000000..30f5dfd9 --- /dev/null +++ b/pkg/wasm/host_crypto.go @@ -0,0 +1,137 @@ +package wasm + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "fmt" + + "github.com/bytecodealliance/wasmtime-go" +) + +// defineHostCryptoImports wires the host-side SHA-256 and HMAC-SHA-512 +// primitives into the WASM linker as the `env::host_sha256` and +// `env::host_hmac_sha512` imports. WASM guests built with TinyGo+WASI +// cannot use Go's stdlib crypto directly when invoked by the executor — +// `crypto/internal/fips140` depends on per-goroutine state populated by +// `_start`, which wasmtime-go's Linker.Instantiate does not invoke. Routing +// hashes through the host bypasses that lifecycle issue, keeps the guest +// binary small, and runs the hash natively on the host (typically faster). +// +// vela-common-go/wasm/hostcrypto is the guest-side facade; this is the +// host-side implementation those imports resolve to. Regression artifact: +// vela-ned/spikes/007-wasmtime-go-init/. +// +// ABI (matching the guest's //go:wasmimport declarations exactly): +// +// env::host_sha256(in_ptr i32, in_len i32, out_ptr i32) -> () +// Reads in_len bytes at in_ptr, writes the 32-byte SHA-256 digest to out_ptr. +// +// env::host_hmac_sha512(key_ptr i32, key_len i32, msg_ptr i32, msg_len i32, out_ptr i32) -> () +// Reads key_len bytes at key_ptr, msg_len bytes at msg_ptr, writes the +// 64-byte HMAC-SHA-512 digest to out_ptr. +// +// Every pointer and length is supplied by the guest and is validated before use; an +// out-of-range or negative value traps the guest rather than being indexed on. The +// wasm-level signatures are unchanged — the *wasmtime.Trap return is host-side only, +// so the guest's //go:wasmimport declarations still match. +func defineHostCryptoImports(linker *wasmtime.Linker, store *wasmtime.Store) error { + if err := linker.DefineFunc(store, "env", "host_sha256", + func(caller *wasmtime.Caller, inPtr int32, inLen int32, outPtr int32) *wasmtime.Trap { + mem, trap := guestMemory(caller) + if trap != nil { + return trap + } + input, trap := guestWindow(mem, inPtr, inLen, "host_sha256 input") + if trap != nil { + return trap + } + out, trap := guestWindow(mem, outPtr, sha256.Size, "host_sha256 output") + if trap != nil { + return trap + } + + digest := sha256.Sum256(input) + copy(out, digest[:]) + return nil + }); err != nil { + return fmt.Errorf("define env::host_sha256: %w", err) + } + + if err := linker.DefineFunc(store, "env", "host_hmac_sha512", + func(caller *wasmtime.Caller, keyPtr int32, keyLen int32, msgPtr int32, msgLen int32, outPtr int32) *wasmtime.Trap { + mem, trap := guestMemory(caller) + if trap != nil { + return trap + } + key, trap := guestWindow(mem, keyPtr, keyLen, "host_hmac_sha512 key") + if trap != nil { + return trap + } + msg, trap := guestWindow(mem, msgPtr, msgLen, "host_hmac_sha512 message") + if trap != nil { + return trap + } + out, trap := guestWindow(mem, outPtr, sha512.Size, "host_hmac_sha512 output") + if trap != nil { + return trap + } + + mac := hmac.New(sha512.New, key) + mac.Write(msg) + copy(out, mac.Sum(nil)) + return nil + }); err != nil { + return fmt.Errorf("define env::host_hmac_sha512: %w", err) + } + + return nil +} + +// guestMemory returns the caller's exported linear memory, or a trap if the guest has +// none. A module reaching these imports without an exported `memory` is malformed, but +// the host must not dereference its way to finding that out. +func guestMemory(caller *wasmtime.Caller) ([]byte, *wasmtime.Trap) { + export := caller.GetExport("memory") + if export == nil { + return nil, wasmtime.NewTrap("host crypto: guest has no exported memory") + } + mem := export.Memory() + if mem == nil { + return nil, wasmtime.NewTrap("host crypto: guest export `memory` is not a memory") + } + return mem.UnsafeData(caller), nil +} + +// guestWindow bounds-checks [ptr, ptr+length) against the guest's linear memory and +// returns that window, or a trap describing what was wrong. +// +// Every argument here crosses the sandbox boundary: the guest chooses the pointers and +// the lengths, and nothing upstream constrains them. Indexing on them directly is how +// the host ends up panicking on a negative length or an out-of-range offset — and a Go +// panic in a host function is NOT converted into a trap, it propagates out through +// Func.Call (wasmtime-go documents this: "if the function `f` panics then the panic +// will be propagated to the caller"). Inside the enclave nothing recovers it, so the +// executor process dies at the request of whichever guest called in. Trapping instead +// keeps it an ordinary guest fault, which the runtime already classifies and evicts for. +// +// The arithmetic is done in int64 so that ptr+length cannot itself overflow and wrap +// back into range, which is exactly how a crafted pair of int32s would slip past a +// naive check. +func guestWindow(mem []byte, ptr int32, length int32, what string) ([]byte, *wasmtime.Trap) { + if length < 0 { + return nil, wasmtime.NewTrap(fmt.Sprintf("host crypto: %s length %d is negative", what, length)) + } + if ptr < 0 { + return nil, wasmtime.NewTrap(fmt.Sprintf("host crypto: %s pointer %d is negative", what, ptr)) + } + + start := int64(ptr) + end := start + int64(length) + if end > int64(len(mem)) { + return nil, wasmtime.NewTrap(fmt.Sprintf( + "host crypto: %s window [%d, %d) exceeds guest memory size %d", what, start, end, len(mem))) + } + + return mem[start:end], nil +} diff --git a/pkg/wasm/host_crypto_test.go b/pkg/wasm/host_crypto_test.go new file mode 100644 index 00000000..c3c093fa --- /dev/null +++ b/pkg/wasm/host_crypto_test.go @@ -0,0 +1,180 @@ +package wasm + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "testing" + + "github.com/bytecodealliance/wasmtime-go" + "github.com/stretchr/testify/require" +) + +// Layer-1 wire-up tests for the host-crypto bridge (see host_crypto.go +// + ADR-011 in vela-ned). These do not exercise the hash functions +// behaviorally — that's covered by vela-ned's integration test (Phase D2) +// and by the spike at vela-ned/spikes/007-wasmtime-go-init/. The job here +// is the cheap, ABI-stable regression check: "did we actually register +// both functions in the linker under the expected env::host_* names?" +// +// A regression where defineHostCryptoImports silently returns nil but +// fails to register one of the two imports would let any consuming app's +// build succeed and instantiation succeed (the unused import would just +// resolve via wasmtime's default lookup machinery), but the first runtime +// call to the missing primitive would trap deep inside the WASM. These +// tests catch that class of bug at vela's PR boundary, before it reaches +// any downstream consumer. + +func TestDefineHostCryptoImports_RegistersBoth(t *testing.T) { + runtime := NewWasmtimeRuntime(testLogger, 0) + defer runtime.Close() + + store := wasmtime.NewStore(runtime.engine) + linker := wasmtime.NewLinker(runtime.engine) + + require.NoError(t, defineHostCryptoImports(linker, store), "defineHostCryptoImports should succeed on a fresh linker") + + require.NotNil(t, linker.Get(store, "env", "host_sha256"), + "env::host_sha256 must be defined in the linker after defineHostCryptoImports") + require.NotNil(t, linker.Get(store, "env", "host_hmac_sha512"), + "env::host_hmac_sha512 must be defined in the linker after defineHostCryptoImports") +} + +func TestDefineHostCryptoImports_RejectsDuplicate(t *testing.T) { + runtime := NewWasmtimeRuntime(testLogger, 0) + defer runtime.Close() + + store := wasmtime.NewStore(runtime.engine) + linker := wasmtime.NewLinker(runtime.engine) + + require.NoError(t, defineHostCryptoImports(linker, store)) + // Second call must fail because the names are already taken — proving + // the first call actually populated the linker (and not, say, silently + // no-op'd). wasmtime.Linker rejects redefining env::host_sha256. + err := defineHostCryptoImports(linker, store) + require.Error(t, err, "second defineHostCryptoImports call should fail due to duplicate import name") +} + +// hostCryptoCallerWat is a guest that does nothing but forward its arguments to the +// host-crypto imports, so a test can drive them with values a real guest would never +// send. The pointers and lengths are guest-controlled in production too — they arrive +// as i32 from inside the sandbox — which is the whole reason the host must validate +// them rather than trust them. +const hostCryptoCallerWat = `(module + (import "env" "host_sha256" (func $sha256 (param i32 i32 i32))) + (import "env" "host_hmac_sha512" (func $hmac (param i32 i32 i32 i32 i32))) + (memory (export "memory") 1) + (func (export "call_sha256") (param $p i32) (param $l i32) (param $o i32) + (call $sha256 (local.get $p) (local.get $l) (local.get $o))) + (func (export "call_hmac") (param $kp i32) (param $kl i32) (param $mp i32) (param $ml i32) (param $o i32) + (call $hmac (local.get $kp) (local.get $kl) (local.get $mp) (local.get $ml) (local.get $o))) +)` + +// newHostCryptoGuest instantiates hostCryptoCallerWat against the real host-crypto +// imports and returns the store, the instance and its linear memory. +func newHostCryptoGuest(t *testing.T) (*wasmtime.Store, *wasmtime.Instance, *wasmtime.Memory) { + t.Helper() + + runtime := NewWasmtimeRuntime(testLogger, 0) + t.Cleanup(func() { runtime.Close() }) + + store := wasmtime.NewStore(runtime.engine) + linker := wasmtime.NewLinker(runtime.engine) + require.NoError(t, defineHostCryptoImports(linker, store)) + + wasmBytes, err := wasmtime.Wat2Wasm(hostCryptoCallerWat) + require.NoError(t, err) + module, err := wasmtime.NewModule(runtime.engine, wasmBytes) + require.NoError(t, err) + instance, err := linker.Instantiate(store, module) + require.NoError(t, err) + + memory := instance.GetExport(store, "memory").Memory() + require.NotNil(t, memory) + return store, instance, memory +} + +// TestHostCryptoComputesCorrectDigests is the baseline the exclusion tests below need: +// without it, "every call traps" would also pass them. +func TestHostCryptoComputesCorrectDigests(t *testing.T) { + store, instance, memory := newHostCryptoGuest(t) + + const ( + inPtr = 0 + outPtr = 1024 + ) + input := []byte("the quick brown fox") + copy(memory.UnsafeData(store)[inPtr:], input) + + _, err := instance.GetFunc(store, "call_sha256").Call(store, int32(inPtr), int32(len(input)), int32(outPtr)) + require.NoError(t, err) + + want := sha256.Sum256(input) + require.Equal(t, want[:], memory.UnsafeData(store)[outPtr:outPtr+32], + "host_sha256 must write the SHA-256 of exactly the requested window") + + // HMAC over the same buffer, key and message deliberately overlapping regions. + const ( + keyPtr = 0 + keyLen = 4 + hmacOutPtr = 2048 + ) + _, err = instance.GetFunc(store, "call_hmac").Call(store, + int32(keyPtr), int32(keyLen), int32(inPtr), int32(len(input)), int32(hmacOutPtr)) + require.NoError(t, err) + + mac := hmac.New(sha512.New, input[:keyLen]) + mac.Write(input) + require.Equal(t, mac.Sum(nil), memory.UnsafeData(store)[hmacOutPtr:hmacOutPtr+64], + "host_hmac_sha512 must write the HMAC-SHA-512 of exactly the requested windows") +} + +// TestHostCryptoRejectsHostileArguments is the exclusion test for the bridge: the +// pointers and lengths come from inside the sandbox, so every one of them can be +// negative or out of range. +// +// What must NOT happen is a Go panic. These run on the calling goroutine, and +// wasmtime-go propagates a panic from a host function straight back out through +// Func.Call — it is not contained as a trap. Inside the enclave that takes the whole +// executor down, from a guest, with no signed failure and no fee: strictly worse than +// the stall this bound exists to prevent. Trapping instead makes it an ordinary guest +// fault, which the runtime already classifies and evicts for. +func TestHostCryptoRejectsHostileArguments(t *testing.T) { + // One page of linear memory. + const memSize = 64 * 1024 + + cases := []struct { + name string + fn string + args []interface{} + }{ + {"sha256 negative length", "call_sha256", []interface{}{int32(0), int32(-1), int32(1024)}}, + {"sha256 negative input pointer", "call_sha256", []interface{}{int32(-1), int32(4), int32(1024)}}, + {"sha256 input past end of memory", "call_sha256", []interface{}{int32(memSize - 8), int32(64), int32(1024)}}, + {"sha256 input length overflows int32", "call_sha256", []interface{}{int32(1024), int32(2147483647), int32(1024)}}, + {"sha256 negative output pointer", "call_sha256", []interface{}{int32(0), int32(4), int32(-1)}}, + {"sha256 output digest past end of memory", "call_sha256", []interface{}{int32(0), int32(4), int32(memSize - 8)}}, + + {"hmac negative key length", "call_hmac", []interface{}{int32(0), int32(-1), int32(0), int32(4), int32(2048)}}, + {"hmac negative message length", "call_hmac", []interface{}{int32(0), int32(4), int32(0), int32(-1), int32(2048)}}, + {"hmac key past end of memory", "call_hmac", []interface{}{int32(memSize - 8), int32(64), int32(0), int32(4), int32(2048)}}, + {"hmac message past end of memory", "call_hmac", []interface{}{int32(0), int32(4), int32(memSize - 8), int32(64), int32(2048)}}, + {"hmac output digest past end of memory", "call_hmac", []interface{}{int32(0), int32(4), int32(0), int32(4), int32(memSize - 8)}}, + {"hmac negative output pointer", "call_hmac", []interface{}{int32(0), int32(4), int32(0), int32(4), int32(-1)}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store, instance, _ := newHostCryptoGuest(t) + fn := instance.GetFunc(store, tc.fn) + require.NotNil(t, fn) + + var err error + require.NotPanics(t, func() { _, err = fn.Call(store, tc.args...) }, + "a hostile argument panicked the HOST: inside the enclave this kills the "+ + "executor process, triggered by any deployed guest") + require.Error(t, err, + "a hostile argument must trap the guest, not be silently accepted") + }) + } +} diff --git a/pkg/wasm/wasmtime_runtime.go b/pkg/wasm/wasmtime_runtime.go index da189dae..96a681ba 100644 --- a/pkg/wasm/wasmtime_runtime.go +++ b/pkg/wasm/wasmtime_runtime.go @@ -271,6 +271,17 @@ func (r *WasmtimeRuntime) loadModuleUnlocked(ctx context.Context, appId common.A if err != nil { return nil, big.NewInt(0), fmt.Errorf("failed to define WASI: %w", err) } + // defineHostCryptoImports wires host-implemented SHA-256 + HMAC-SHA-512 + // into the linker as the `env::host_sha256` / `env::host_hmac_sha512` + // imports. WASM guests built with TinyGo+WASI cannot use Go's stdlib + // crypto directly because Go-1.24's `crypto/internal/fips140` indicator + // depends on per-goroutine state populated by `_start`, which we never + // call (see host_crypto.go for the full rationale). This is the seam to + // add future host-implemented primitives — if another stdlib package + // trips a similar lifecycle issue, expose it here alongside crypto. + if err := defineHostCryptoImports(linker, store); err != nil { + return nil, big.NewInt(0), err + } // Instantiate the module using the module-specific store instance, err := linker.Instantiate(store, module) @@ -409,6 +420,17 @@ func (r *WasmtimeRuntime) deployUnlocked(ctx context.Context, appId common.Appli if err != nil { return nil, big.NewInt(0), fmt.Errorf("failed to define WASI: %w", err) } + // defineHostCryptoImports wires host-implemented SHA-256 + HMAC-SHA-512 + // into the linker as the `env::host_sha256` / `env::host_hmac_sha512` + // imports. WASM guests built with TinyGo+WASI cannot use Go's stdlib + // crypto directly because Go-1.24's `crypto/internal/fips140` indicator + // depends on per-goroutine state populated by `_start`, which we never + // call (see host_crypto.go for the full rationale). This is the seam to + // add future host-implemented primitives — if another stdlib package + // trips a similar lifecycle issue, expose it here alongside crypto. + if err := defineHostCryptoImports(linker, store); err != nil { + return nil, big.NewInt(0), err + } // Instantiate the module using the module-specific store instance, err := linker.Instantiate(store, module) @@ -596,8 +618,10 @@ func (r *WasmtimeRuntime) Deposit(ctx context.Context, appId common.ApplicationI return depositResult.State, depositResult.Events, depositResult.AppEvents, depositResult.Fuel.ToInt(), nil } -// ProcessRequest processes a request and returns the new state, events, withdrawals, and optionally a deanonymization report -func (r *WasmtimeRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { +// ProcessRequest processes a request and returns the new state, events, withdrawals, and optionally a deanonymization report. +// blockTimestamp is the chain-attested block.timestamp at request enqueue and is passed through to the WASM guest as an +// additional int64 parameter on the process_request export. +func (r *WasmtimeRuntime) ProcessRequest(ctx context.Context, appId common.ApplicationIdType, sender ethCommon.Address, requestType common.RequestType, blockTimestamp uint64, payload []byte, state []byte, wasm []byte) ([]byte, []common.PlainEvent, []common.AppEvent, []common.Withdrawal, []byte, *big.Int, *apperrors.RequestFailure) { r.log.Info("Wasmtime Runtime: Processing request for application %d (type: %s, payload size: %d, state size: %d)", appId, requestType, len(payload), len(state)) wasmAppId, err := ToWasmType(appId) @@ -697,9 +721,9 @@ func (r *WasmtimeRuntime) ProcessRequest(ctx context.Context, appId common.Appli } // Call the process_request function - // Wasm supports only int64, so we cast appId to int64 + // Wasm supports only int64, so we cast appId and blockTimestamp to int64 // requestType is passed as int32 to the WASM module - result, err := processRequestFunc.Call(appModule.store, wasmAppId, senderPtr, int32(len(senderBytes)), int32(requestType), payloadPtr, int32(len(payload)), statePtr, int32(len(state))) + result, err := processRequestFunc.Call(appModule.store, wasmAppId, senderPtr, int32(len(senderBytes)), int32(requestType), int64(blockTimestamp), payloadPtr, int32(len(payload)), statePtr, int32(len(state))) if err != nil { return nil, nil, nil, nil, nil, big.NewInt(0), apperrors.New(apperrors.CodeRequestFuncFailed, fmt.Sprintf("failed to call process_request: %v", err)) } diff --git a/pkg/wasm/wasmtime_runtime_test.go b/pkg/wasm/wasmtime_runtime_test.go index ba724ff7..a54bc61a 100644 --- a/pkg/wasm/wasmtime_runtime_test.go +++ b/pkg/wasm/wasmtime_runtime_test.go @@ -28,8 +28,8 @@ import ( // // Signatures: // -// process_request (param i64 i32 i32 i32 i32 i32 i32 i32) (result i32) -// (appId, senderPtr, senderLen, requestType, payloadPtr, payloadLen, statePtr, stateLen) +// process_request (param i64 i32 i32 i32 i64 i32 i32 i32 i32) (result i32) +// (appId, senderPtr, senderLen, requestType, blockTimestamp, payloadPtr, payloadLen, statePtr, stateLen) // trusted_request (param i64 i32 i32 i32 i32) (result i32) // (appId, payloadPtr, payloadLen, statePtr, stateLen) // load_module (param i64) (result i32) — called by getOrLoadModule on first load @@ -77,8 +77,8 @@ const dispatchTestWat = `(module (func (export "deallocate") (param i32 i32) ) - ;; process_request: 8-arg ABI — ignores all params, returns fixed offset 100 - (func (export "process_request") (param i64 i32 i32 i32 i32 i32 i32 i32) (result i32) + ;; process_request: 9-arg ABI — ignores all params, returns fixed offset 100 + (func (export "process_request") (param i64 i32 i32 i32 i64 i32 i32 i32 i32) (result i32) i32.const 100 ) @@ -442,13 +442,13 @@ func TestProcessRequest_DispatchesByRequestType(t *testing.T) { state := []byte("{}") // TrustProcess -> trusted_request (state discriminator byte == 2) - trustedState, _, _, _, _, _, failure := runtime.ProcessRequest(ctx, appId, ethCommon.Address{}, common.TrustProcess, payload, state, wasmBytes) + trustedState, _, _, _, _, _, failure := runtime.ProcessRequest(ctx, appId, ethCommon.Address{}, common.TrustProcess, uint64(0), payload, state, wasmBytes) require.Nil(t, failure, "TrustProcess must succeed via trusted_request") require.Equal(t, []byte{2}, trustedState, "TrustProcess must dispatch to trusted_request (state=[2])") // Process -> process_request (state discriminator byte == 1) // Same appId is fine — the cached module exports both functions. - procState, _, _, _, _, _, failure := runtime.ProcessRequest(ctx, appId, ethCommon.Address{}, common.Process, payload, state, wasmBytes) + procState, _, _, _, _, _, failure := runtime.ProcessRequest(ctx, appId, ethCommon.Address{}, common.Process, uint64(0), payload, state, wasmBytes) require.Nil(t, failure, "Process must succeed via process_request") require.Equal(t, []byte{1}, procState, "Process must dispatch to process_request (state=[1])") } diff --git a/tests/system/simple_app_system_test.go b/tests/system/simple_app_system_test.go index d1a7c6d2..a7fd6cb4 100644 --- a/tests/system/simple_app_system_test.go +++ b/tests/system/simple_app_system_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/HorizenOfficial/vela-common-go/subtypes" simpleapp "github.com/HorizenOfficial/vela/app/simple/app" "github.com/HorizenOfficial/vela/pkg/common" cryptotypes "github.com/HorizenOfficial/vela/pkg/common/crypto" @@ -142,7 +143,7 @@ func depositToSimpleApp(t *testing.T, suite *testutil.SystemTestSuite, cryptoHel // Wait for, decrypt and verify deposit event (using privacy-preserving subtype set) userSeed, err := cryptoHelper.ComputeSeed(user) require.NoError(t, err) - depositEvent, err := suite.WaitForEventBySubtypes(user, executor.AllSubtypes(userSeed, executor.DefaultSubtypeN), timeout) + depositEvent, err := suite.WaitForEventBySubtypes(user, subtypes.AllSubtypes(userSeed, subtypes.DefaultSubtypeN), timeout) require.NoError(t, err) decryptedDepositData, err := cryptoHelper.DecryptEvent(user, depositEvent, executorPubKey) require.NoError(t, err) @@ -187,7 +188,7 @@ func withdrawFromSimpleApp(t *testing.T, suite *testutil.SystemTestSuite, crypto // Wait for, decrypt and verify withdrawal event (using privacy-preserving subtype set) userSeed, err := cryptoHelper.ComputeSeed(user) require.NoError(t, err) - withdrawalEvent, err := suite.WaitForEventBySubtypes(user, executor.AllSubtypes(userSeed, executor.DefaultSubtypeN), timeout) + withdrawalEvent, err := suite.WaitForEventBySubtypes(user, subtypes.AllSubtypes(userSeed, subtypes.DefaultSubtypeN), timeout) require.NoError(t, err) decryptedWithdrawalData, err := cryptoHelper.DecryptEvent(user, withdrawalEvent, executorPubKey) require.NoError(t, err) @@ -488,7 +489,7 @@ func TestSimpleAppCompareAction(t *testing.T) { // Wait for action event (using privacy-preserving subtype set) user1Seed, err := cryptoHelper.ComputeSeed(user1Address) require.NoError(t, err) - actionEvent, err := suite.WaitForEventBySubtypes(user1Address, executor.AllSubtypes(user1Seed, executor.DefaultSubtypeN), timeout_value) + actionEvent, err := suite.WaitForEventBySubtypes(user1Address, subtypes.AllSubtypes(user1Seed, subtypes.DefaultSubtypeN), timeout_value) require.NoError(t, err) require.NotNil(t, actionEvent) @@ -508,7 +509,7 @@ func TestSimpleAppCompareAction(t *testing.T) { // Find the action event by matching user1's privacy-preserving subtype set user1SubtypeSet := make(map[[32]byte]struct{}) - for _, st := range executor.AllSubtypes(user1Seed, executor.DefaultSubtypeN) { + for _, st := range subtypes.AllSubtypes(user1Seed, subtypes.DefaultSubtypeN) { user1SubtypeSet[st] = struct{}{} } var compareEvent *common.Event @@ -883,7 +884,7 @@ func TestSimpleAppERC20DepositAndWithdraw(t *testing.T) { // Verify deposit event carries token address userSeed, err := cryptoHelper.ComputeSeed(userAddress) require.NoError(t, err) - depositEvent, err := suite.WaitForEventBySubtypes(userAddress, executor.AllSubtypes(userSeed, executor.DefaultSubtypeN), timeout) + depositEvent, err := suite.WaitForEventBySubtypes(userAddress, subtypes.AllSubtypes(userSeed, subtypes.DefaultSubtypeN), timeout) require.NoError(t, err) decryptedDepositData, err := cryptoHelper.DecryptEvent(userAddress, depositEvent, executorPubKey) require.NoError(t, err)