From 3f9482528624bd92f2c0f54dd6e18c35ef19c3c8 Mon Sep 17 00:00:00 2001 From: zy0n Date: Fri, 8 May 2026 19:11:30 -0400 Subject: [PATCH 1/8] feat: Add RelayAdapt7702 helper and related functionality --- src/__tests__/railgun-engine.test.ts | 2 + src/abi/V2/Registry.json | 346 +++ src/abi/V2/RelayAdapt7702.json | 864 +++++++ src/abi/abi.ts | 4 + src/abi/typechain/Registry.ts | 439 ++++ src/abi/typechain/RelayAdapt7702.ts | 553 +++++ .../typechain/factories/Registry__factory.ts | 363 +++ .../factories/RelayAdapt7702__factory.ts | 854 +++++++ src/abi/typechain/factories/index.ts | 4 + src/abi/typechain/index.ts | 8 + src/contracts/contract-store.ts | 6 + .../railgun-versioned-smart-contracts.ts | 5 +- src/contracts/relay-adapt/V2/registry.ts | 29 + .../relay-adapt/V2/relay-adapt-7702.ts | 488 ++++ .../__tests__/relay-adapt-7702.test.ts | 1979 +++++++++++++++++ src/contracts/relay-adapt/index.ts | 3 + .../relay-adapt/relay-adapt-7702-helper.ts | 186 ++ .../relay-adapt/relay-adapt-types.ts | 6 + .../relay-adapt-versioned-smart-contracts.ts | 103 +- src/index.ts | 1 + .../__tests__/ephemeral-key.test.ts | 105 + src/key-derivation/bech32.ts | 2 +- src/key-derivation/ephemeral-key.ts | 66 + src/key-derivation/index.ts | 1 + src/models/index.ts | 1 + src/models/relay-adapt-types.ts | 11 + src/models/typechain-types.ts | 4 +- src/railgun-engine.ts | 64 + src/test/config-overrides.test.ts | 23 +- src/test/config.test.ts | 11 +- src/transaction/__tests__/eip7702.test.ts | 34 + .../relay-adapt-7702-signature.test.ts | 69 + src/transaction/eip7702.ts | 27 + src/transaction/index.ts | 2 + src/transaction/relay-adapt-7702-signature.ts | 71 + .../relay-adapt-7702-validator.test.ts | 18 + src/validation/extract-transaction-data-v2.ts | 15 +- src/validation/extract-transaction-data.ts | 4 + src/validation/index.ts | 1 + src/validation/poi-validation.ts | 2 + src/validation/relay-adapt-7702-validator.ts | 58 + .../__tests__/ephemeral-key-manager.test.ts | 85 + src/wallet/__tests__/railgun-wallet.test.ts | 85 + src/wallet/abstract-wallet.ts | 4 + src/wallet/ephemeral-key-manager.ts | 79 + src/wallet/index.ts | 1 + src/wallet/railgun-wallet.ts | 239 +- 47 files changed, 7311 insertions(+), 14 deletions(-) create mode 100644 src/abi/V2/Registry.json create mode 100644 src/abi/V2/RelayAdapt7702.json create mode 100644 src/abi/typechain/Registry.ts create mode 100644 src/abi/typechain/RelayAdapt7702.ts create mode 100644 src/abi/typechain/factories/Registry__factory.ts create mode 100644 src/abi/typechain/factories/RelayAdapt7702__factory.ts create mode 100644 src/contracts/relay-adapt/V2/registry.ts create mode 100644 src/contracts/relay-adapt/V2/relay-adapt-7702.ts create mode 100644 src/contracts/relay-adapt/__tests__/relay-adapt-7702.test.ts create mode 100644 src/contracts/relay-adapt/relay-adapt-7702-helper.ts create mode 100644 src/contracts/relay-adapt/relay-adapt-types.ts create mode 100644 src/key-derivation/__tests__/ephemeral-key.test.ts create mode 100644 src/key-derivation/ephemeral-key.ts create mode 100644 src/models/relay-adapt-types.ts create mode 100644 src/transaction/__tests__/eip7702.test.ts create mode 100644 src/transaction/__tests__/relay-adapt-7702-signature.test.ts create mode 100644 src/transaction/eip7702.ts create mode 100644 src/transaction/relay-adapt-7702-signature.ts create mode 100644 src/validation/__tests__/relay-adapt-7702-validator.test.ts create mode 100644 src/validation/relay-adapt-7702-validator.ts create mode 100644 src/wallet/__tests__/ephemeral-key-manager.test.ts create mode 100644 src/wallet/ephemeral-key-manager.ts diff --git a/src/__tests__/railgun-engine.test.ts b/src/__tests__/railgun-engine.test.ts index 11607a01..8aebea8e 100644 --- a/src/__tests__/railgun-engine.test.ts +++ b/src/__tests__/railgun-engine.test.ts @@ -321,6 +321,8 @@ describe('railgun-engine', function test() { { [TXIDVersion.V2_PoseidonMerkle]: 24, [TXIDVersion.V3_PoseidonMerkle]: 24 }, 0, !isV2Test(), // supportsV3 + config.contracts.relayAdapt7702, + config.contracts.adapt7702Deployer, ); const balance = await token.balanceOf(ethersWallet.address); diff --git a/src/abi/V2/Registry.json b/src/abi/V2/Registry.json new file mode 100644 index 00000000..ec17c049 --- /dev/null +++ b/src/abi/V2/Registry.json @@ -0,0 +1,346 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_owner", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "indexed": true, + "internalType": "address", + "name": "contractAddress", + "type": "address" + } + ], + "name": "ContractDeprecated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "indexed": true, + "internalType": "address", + "name": "contractAddress", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "version", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "deprecated", + "type": "bool" + } + ], + "name": "ContractSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "deployment", + "type": "address" + } + ], + "name": "Registered", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "name", + "type": "string" + } + ], + "name": "deprecateContract", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "getAllContracts", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + }, + { + "internalType": "uint256", + "name": "version", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "deprecated", + "type": "bool" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "uint256", + "name": "nameIndex", + "type": "uint256" + } + ], + "internalType": "struct Registry.RegistryEntry[]", + "name": "", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "name", + "type": "string" + } + ], + "name": "getContract", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + }, + { + "internalType": "uint256", + "name": "version", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "deprecated", + "type": "bool" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "uint256", + "name": "nameIndex", + "type": "uint256" + } + ], + "internalType": "struct Registry.RegistryEntry", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "name", + "type": "string" + } + ], + "name": "getContractAddress", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "isDeployed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "nameIndex", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "registryIndex", + "outputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + }, + { + "internalType": "uint256", + "name": "version", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "deprecated", + "type": "bool" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "uint256", + "name": "nameIndex", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "address", + "name": "contractAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "version", + "type": "uint256" + } + ], + "name": "setContract", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] \ No newline at end of file diff --git a/src/abi/V2/RelayAdapt7702.json b/src/abi/V2/RelayAdapt7702.json new file mode 100644 index 00000000..3b09f427 --- /dev/null +++ b/src/abi/V2/RelayAdapt7702.json @@ -0,0 +1,864 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_railgun", + "type": "address" + }, + { + "internalType": "address", + "name": "_wBase", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "callIndex", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "revertReason", + "type": "bytes" + } + ], + "name": "CallFailed", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "callIndex", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "revertReason", + "type": "bytes" + } + ], + "name": "CallError", + "type": "event" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "EXECUTE_TYPEHASH", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MULTICALL_TYPEHASH", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "adaptImplementation", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "components": [ + { + "internalType": "uint256", + "name": "x", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "y", + "type": "uint256" + } + ], + "internalType": "struct G1Point", + "name": "a", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256[2]", + "name": "x", + "type": "uint256[2]" + }, + { + "internalType": "uint256[2]", + "name": "y", + "type": "uint256[2]" + } + ], + "internalType": "struct G2Point", + "name": "b", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "x", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "y", + "type": "uint256" + } + ], + "internalType": "struct G1Point", + "name": "c", + "type": "tuple" + } + ], + "internalType": "struct SnarkProof", + "name": "proof", + "type": "tuple" + }, + { + "internalType": "bytes32", + "name": "merkleRoot", + "type": "bytes32" + }, + { + "internalType": "bytes32[]", + "name": "nullifiers", + "type": "bytes32[]" + }, + { + "internalType": "bytes32[]", + "name": "commitments", + "type": "bytes32[]" + }, + { + "components": [ + { + "internalType": "uint16", + "name": "treeNumber", + "type": "uint16" + }, + { + "internalType": "uint72", + "name": "minGasPrice", + "type": "uint72" + }, + { + "internalType": "enum UnshieldType", + "name": "unshield", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "chainID", + "type": "uint64" + }, + { + "internalType": "address", + "name": "adaptContract", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "adaptParams", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "bytes32[4]", + "name": "ciphertext", + "type": "bytes32[4]" + }, + { + "internalType": "bytes32", + "name": "blindedSenderViewingKey", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "blindedReceiverViewingKey", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "annotationData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "memo", + "type": "bytes" + } + ], + "internalType": "struct CommitmentCiphertext[]", + "name": "commitmentCiphertext", + "type": "tuple[]" + } + ], + "internalType": "struct BoundParams", + "name": "boundParams", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "npk", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "enum TokenType", + "name": "tokenType", + "type": "uint8" + }, + { + "internalType": "address", + "name": "tokenAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenSubID", + "type": "uint256" + } + ], + "internalType": "struct TokenData", + "name": "token", + "type": "tuple" + }, + { + "internalType": "uint120", + "name": "value", + "type": "uint120" + } + ], + "internalType": "struct CommitmentPreimage", + "name": "unshieldPreimage", + "type": "tuple" + } + ], + "internalType": "struct Transaction[]", + "name": "_transactions", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "bool", + "name": "requireSuccess", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "minGasLimit", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "internalType": "struct RelayAdapt7702.Call[]", + "name": "calls", + "type": "tuple[]" + } + ], + "internalType": "struct RelayAdapt7702.ActionData", + "name": "_actionData", + "type": "tuple" + }, + { + "internalType": "bytes", + "name": "_signature", + "type": "bytes" + } + ], + "name": "execute", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "components": [ + { + "internalType": "uint256", + "name": "x", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "y", + "type": "uint256" + } + ], + "internalType": "struct G1Point", + "name": "a", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256[2]", + "name": "x", + "type": "uint256[2]" + }, + { + "internalType": "uint256[2]", + "name": "y", + "type": "uint256[2]" + } + ], + "internalType": "struct G2Point", + "name": "b", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "x", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "y", + "type": "uint256" + } + ], + "internalType": "struct G1Point", + "name": "c", + "type": "tuple" + } + ], + "internalType": "struct SnarkProof", + "name": "proof", + "type": "tuple" + }, + { + "internalType": "bytes32", + "name": "merkleRoot", + "type": "bytes32" + }, + { + "internalType": "bytes32[]", + "name": "nullifiers", + "type": "bytes32[]" + }, + { + "internalType": "bytes32[]", + "name": "commitments", + "type": "bytes32[]" + }, + { + "components": [ + { + "internalType": "uint16", + "name": "treeNumber", + "type": "uint16" + }, + { + "internalType": "uint72", + "name": "minGasPrice", + "type": "uint72" + }, + { + "internalType": "enum UnshieldType", + "name": "unshield", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "chainID", + "type": "uint64" + }, + { + "internalType": "address", + "name": "adaptContract", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "adaptParams", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "bytes32[4]", + "name": "ciphertext", + "type": "bytes32[4]" + }, + { + "internalType": "bytes32", + "name": "blindedSenderViewingKey", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "blindedReceiverViewingKey", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "annotationData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "memo", + "type": "bytes" + } + ], + "internalType": "struct CommitmentCiphertext[]", + "name": "commitmentCiphertext", + "type": "tuple[]" + } + ], + "internalType": "struct BoundParams", + "name": "boundParams", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "npk", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "enum TokenType", + "name": "tokenType", + "type": "uint8" + }, + { + "internalType": "address", + "name": "tokenAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenSubID", + "type": "uint256" + } + ], + "internalType": "struct TokenData", + "name": "token", + "type": "tuple" + }, + { + "internalType": "uint120", + "name": "value", + "type": "uint120" + } + ], + "internalType": "struct CommitmentPreimage", + "name": "unshieldPreimage", + "type": "tuple" + } + ], + "internalType": "struct Transaction[]", + "name": "_transactions", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "bool", + "name": "requireSuccess", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "minGasLimit", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "internalType": "struct RelayAdapt7702.Call[]", + "name": "calls", + "type": "tuple[]" + } + ], + "internalType": "struct RelayAdapt7702.ActionData", + "name": "_actionData", + "type": "tuple" + } + ], + "name": "getExecutePayloadHash", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "_requireSuccess", + "type": "bool" + }, + { + "components": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "internalType": "struct RelayAdapt7702.Call[]", + "name": "_calls", + "type": "tuple[]" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + } + ], + "name": "getMulticallPayloadHash", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "_requireSuccess", + "type": "bool" + }, + { + "components": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "internalType": "struct RelayAdapt7702.Call[]", + "name": "_calls", + "type": "tuple[]" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_signature", + "type": "bytes" + } + ], + "name": "multicall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "nonce", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "railgun", + "outputs": [ + { + "internalType": "contract RailgunSmartWallet", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "internalType": "bytes32", + "name": "npk", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "enum TokenType", + "name": "tokenType", + "type": "uint8" + }, + { + "internalType": "address", + "name": "tokenAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenSubID", + "type": "uint256" + } + ], + "internalType": "struct TokenData", + "name": "token", + "type": "tuple" + }, + { + "internalType": "uint120", + "name": "value", + "type": "uint120" + } + ], + "internalType": "struct CommitmentPreimage", + "name": "preimage", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bytes32[3]", + "name": "encryptedBundle", + "type": "bytes32[3]" + }, + { + "internalType": "bytes32", + "name": "shieldKey", + "type": "bytes32" + } + ], + "internalType": "struct ShieldCiphertext", + "name": "ciphertext", + "type": "tuple" + } + ], + "internalType": "struct ShieldRequest[]", + "name": "_shieldRequests", + "type": "tuple[]" + } + ], + "name": "shield", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "internalType": "enum TokenType", + "name": "tokenType", + "type": "uint8" + }, + { + "internalType": "address", + "name": "tokenAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenSubID", + "type": "uint256" + } + ], + "internalType": "struct TokenData", + "name": "token", + "type": "tuple" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "internalType": "struct RelayAdapt7702.TokenTransfer[]", + "name": "_transfers", + "type": "tuple[]" + } + ], + "name": "transfer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "name": "unwrapBase", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "wBase", + "outputs": [ + { + "internalType": "contract IWBase", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "name": "wrapBase", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "stateMutability": "payable", + "type": "receive" + } +] diff --git a/src/abi/abi.ts b/src/abi/abi.ts index 01eee115..55a1afc3 100644 --- a/src/abi/abi.ts +++ b/src/abi/abi.ts @@ -4,6 +4,8 @@ import ABIRailgunLogic_LegacyEvents from './V1/RailgunLogic_LegacyEvents.json'; // V2 import ABIRailgunSmartWallet_Legacy_PreMar23 from './V2/RailgunSmartWallet_Legacy_PreMar23.json'; import ABIRelayAdapt from './V2/RelayAdapt.json'; +import ABIRelayAdapt7702 from './V2/RelayAdapt7702.json'; +import ABIRegistry from './V2/Registry.json'; // V2.1 import ABIRailgunSmartWallet from './V2.1/RailgunSmartWallet.json'; @@ -18,6 +20,8 @@ export { ABIRailgunSmartWallet_Legacy_PreMar23, ABIRailgunSmartWallet, ABIRelayAdapt, + ABIRelayAdapt7702, + ABIRegistry, ABIPoseidonMerkleAccumulator, ABIPoseidonMerkleVerifier, ABITokenVault, diff --git a/src/abi/typechain/Registry.ts b/src/abi/typechain/Registry.ts new file mode 100644 index 00000000..d5755a4a --- /dev/null +++ b/src/abi/typechain/Registry.ts @@ -0,0 +1,439 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ +import type { + BaseContract, + BigNumberish, + BytesLike, + FunctionFragment, + Result, + Interface, + EventFragment, + AddressLike, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedLogDescription, + TypedListener, + TypedContractMethod, +} from "./common"; + +export declare namespace Registry { + export type RegistryEntryStruct = { + implementation: AddressLike; + version: BigNumberish; + deprecated: boolean; + name: string; + nameIndex: BigNumberish; + }; + + export type RegistryEntryStructOutput = [ + implementation: string, + version: bigint, + deprecated: boolean, + name: string, + nameIndex: bigint + ] & { + implementation: string; + version: bigint; + deprecated: boolean; + name: string; + nameIndex: bigint; + }; +} + +export interface RegistryInterface extends Interface { + getFunction( + nameOrSignature: + | "deprecateContract" + | "getAllContracts" + | "getContract" + | "getContractAddress" + | "isDeployed" + | "nameIndex" + | "owner" + | "registryIndex" + | "renounceOwnership" + | "setContract" + | "transferOwnership" + ): FunctionFragment; + + getEvent( + nameOrSignatureOrTopic: + | "ContractDeprecated" + | "ContractSet" + | "OwnershipTransferred" + | "Registered" + ): EventFragment; + + encodeFunctionData( + functionFragment: "deprecateContract", + values: [string] + ): string; + encodeFunctionData( + functionFragment: "getAllContracts", + values?: undefined + ): string; + encodeFunctionData(functionFragment: "getContract", values: [string]): string; + encodeFunctionData( + functionFragment: "getContractAddress", + values: [string] + ): string; + encodeFunctionData( + functionFragment: "isDeployed", + values: [AddressLike] + ): string; + encodeFunctionData( + functionFragment: "nameIndex", + values: [BytesLike] + ): string; + encodeFunctionData(functionFragment: "owner", values?: undefined): string; + encodeFunctionData( + functionFragment: "registryIndex", + values: [BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "renounceOwnership", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "setContract", + values: [string, AddressLike, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "transferOwnership", + values: [AddressLike] + ): string; + + decodeFunctionResult( + functionFragment: "deprecateContract", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "getAllContracts", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "getContract", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "getContractAddress", + data: BytesLike + ): Result; + decodeFunctionResult(functionFragment: "isDeployed", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "nameIndex", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "owner", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "registryIndex", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "renounceOwnership", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "setContract", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "transferOwnership", + data: BytesLike + ): Result; +} + +export namespace ContractDeprecatedEvent { + export type InputTuple = [name: string, contractAddress: AddressLike]; + export type OutputTuple = [name: string, contractAddress: string]; + export interface OutputObject { + name: string; + contractAddress: string; + } + export type Event = TypedContractEvent; + export type Filter = TypedDeferredTopicFilter; + export type Log = TypedEventLog; + export type LogDescription = TypedLogDescription; +} + +export namespace ContractSetEvent { + export type InputTuple = [ + name: string, + contractAddress: AddressLike, + version: BigNumberish, + deprecated: boolean + ]; + export type OutputTuple = [ + name: string, + contractAddress: string, + version: bigint, + deprecated: boolean + ]; + export interface OutputObject { + name: string; + contractAddress: string; + version: bigint; + deprecated: boolean; + } + export type Event = TypedContractEvent; + export type Filter = TypedDeferredTopicFilter; + export type Log = TypedEventLog; + export type LogDescription = TypedLogDescription; +} + +export namespace OwnershipTransferredEvent { + export type InputTuple = [previousOwner: AddressLike, newOwner: AddressLike]; + export type OutputTuple = [previousOwner: string, newOwner: string]; + export interface OutputObject { + previousOwner: string; + newOwner: string; + } + export type Event = TypedContractEvent; + export type Filter = TypedDeferredTopicFilter; + export type Log = TypedEventLog; + export type LogDescription = TypedLogDescription; +} + +export namespace RegisteredEvent { + export type InputTuple = [deployment: AddressLike]; + export type OutputTuple = [deployment: string]; + export interface OutputObject { + deployment: string; + } + export type Event = TypedContractEvent; + export type Filter = TypedDeferredTopicFilter; + export type Log = TypedEventLog; + export type LogDescription = TypedLogDescription; +} + +export interface Registry extends BaseContract { + connect(runner?: ContractRunner | null): BaseContract; + attach(addressOrName: AddressLike): this; + deployed(): Promise; + + interface: RegistryInterface; + + queryFilter( + event: TCEvent, + fromBlockOrBlockhash?: string | number | undefined, + toBlock?: string | number | undefined + ): Promise>>; + queryFilter( + filter: TypedDeferredTopicFilter, + fromBlockOrBlockhash?: string | number | undefined, + toBlock?: string | number | undefined + ): Promise>>; + + on( + event: TCEvent, + listener: TypedListener + ): Promise; + on( + filter: TypedDeferredTopicFilter, + listener: TypedListener + ): Promise; + + once( + event: TCEvent, + listener: TypedListener + ): Promise; + once( + filter: TypedDeferredTopicFilter, + listener: TypedListener + ): Promise; + + listeners( + event: TCEvent + ): Promise>>; + listeners(eventName?: string): Promise>; + removeAllListeners( + event?: TCEvent + ): Promise; + + deprecateContract: TypedContractMethod<[name: string], [void], "nonpayable">; + + getAllContracts: TypedContractMethod< + [], + [Registry.RegistryEntryStructOutput[]], + "view" + >; + + getContract: TypedContractMethod< + [name: string], + [Registry.RegistryEntryStructOutput], + "view" + >; + + getContractAddress: TypedContractMethod<[name: string], [string], "view">; + + isDeployed: TypedContractMethod<[arg0: AddressLike], [boolean], "view">; + + nameIndex: TypedContractMethod<[arg0: BytesLike], [bigint], "view">; + + owner: TypedContractMethod<[], [string], "view">; + + registryIndex: TypedContractMethod< + [arg0: BigNumberish], + [ + [string, bigint, boolean, string, bigint] & { + implementation: string; + version: bigint; + deprecated: boolean; + name: string; + nameIndex: bigint; + } + ], + "view" + >; + + renounceOwnership: TypedContractMethod<[], [void], "nonpayable">; + + setContract: TypedContractMethod< + [name: string, contractAddress: AddressLike, version: BigNumberish], + [void], + "nonpayable" + >; + + transferOwnership: TypedContractMethod< + [newOwner: AddressLike], + [void], + "nonpayable" + >; + + getFunction( + key: string | FunctionFragment + ): T; + + getFunction( + nameOrSignature: "deprecateContract" + ): TypedContractMethod<[name: string], [void], "nonpayable">; + getFunction( + nameOrSignature: "getAllContracts" + ): TypedContractMethod<[], [Registry.RegistryEntryStructOutput[]], "view">; + getFunction( + nameOrSignature: "getContract" + ): TypedContractMethod< + [name: string], + [Registry.RegistryEntryStructOutput], + "view" + >; + getFunction( + nameOrSignature: "getContractAddress" + ): TypedContractMethod<[name: string], [string], "view">; + getFunction( + nameOrSignature: "isDeployed" + ): TypedContractMethod<[arg0: AddressLike], [boolean], "view">; + getFunction( + nameOrSignature: "nameIndex" + ): TypedContractMethod<[arg0: BytesLike], [bigint], "view">; + getFunction( + nameOrSignature: "owner" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "registryIndex" + ): TypedContractMethod< + [arg0: BigNumberish], + [ + [string, bigint, boolean, string, bigint] & { + implementation: string; + version: bigint; + deprecated: boolean; + name: string; + nameIndex: bigint; + } + ], + "view" + >; + getFunction( + nameOrSignature: "renounceOwnership" + ): TypedContractMethod<[], [void], "nonpayable">; + getFunction( + nameOrSignature: "setContract" + ): TypedContractMethod< + [name: string, contractAddress: AddressLike, version: BigNumberish], + [void], + "nonpayable" + >; + getFunction( + nameOrSignature: "transferOwnership" + ): TypedContractMethod<[newOwner: AddressLike], [void], "nonpayable">; + + getEvent( + key: "ContractDeprecated" + ): TypedContractEvent< + ContractDeprecatedEvent.InputTuple, + ContractDeprecatedEvent.OutputTuple, + ContractDeprecatedEvent.OutputObject + >; + getEvent( + key: "ContractSet" + ): TypedContractEvent< + ContractSetEvent.InputTuple, + ContractSetEvent.OutputTuple, + ContractSetEvent.OutputObject + >; + getEvent( + key: "OwnershipTransferred" + ): TypedContractEvent< + OwnershipTransferredEvent.InputTuple, + OwnershipTransferredEvent.OutputTuple, + OwnershipTransferredEvent.OutputObject + >; + getEvent( + key: "Registered" + ): TypedContractEvent< + RegisteredEvent.InputTuple, + RegisteredEvent.OutputTuple, + RegisteredEvent.OutputObject + >; + + filters: { + "ContractDeprecated(string,address)": TypedContractEvent< + ContractDeprecatedEvent.InputTuple, + ContractDeprecatedEvent.OutputTuple, + ContractDeprecatedEvent.OutputObject + >; + ContractDeprecated: TypedContractEvent< + ContractDeprecatedEvent.InputTuple, + ContractDeprecatedEvent.OutputTuple, + ContractDeprecatedEvent.OutputObject + >; + + "ContractSet(string,address,uint256,bool)": TypedContractEvent< + ContractSetEvent.InputTuple, + ContractSetEvent.OutputTuple, + ContractSetEvent.OutputObject + >; + ContractSet: TypedContractEvent< + ContractSetEvent.InputTuple, + ContractSetEvent.OutputTuple, + ContractSetEvent.OutputObject + >; + + "OwnershipTransferred(address,address)": TypedContractEvent< + OwnershipTransferredEvent.InputTuple, + OwnershipTransferredEvent.OutputTuple, + OwnershipTransferredEvent.OutputObject + >; + OwnershipTransferred: TypedContractEvent< + OwnershipTransferredEvent.InputTuple, + OwnershipTransferredEvent.OutputTuple, + OwnershipTransferredEvent.OutputObject + >; + + "Registered(address)": TypedContractEvent< + RegisteredEvent.InputTuple, + RegisteredEvent.OutputTuple, + RegisteredEvent.OutputObject + >; + Registered: TypedContractEvent< + RegisteredEvent.InputTuple, + RegisteredEvent.OutputTuple, + RegisteredEvent.OutputObject + >; + }; +} diff --git a/src/abi/typechain/RelayAdapt7702.ts b/src/abi/typechain/RelayAdapt7702.ts new file mode 100644 index 00000000..a9ab34df --- /dev/null +++ b/src/abi/typechain/RelayAdapt7702.ts @@ -0,0 +1,553 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ +import type { + BaseContract, + BigNumberish, + BytesLike, + FunctionFragment, + Result, + Interface, + EventFragment, + AddressLike, + ContractRunner, + ContractMethod, + Listener, +} from "ethers"; +import type { + TypedContractEvent, + TypedDeferredTopicFilter, + TypedEventLog, + TypedLogDescription, + TypedListener, + TypedContractMethod, +} from "./common"; + +export type G1PointStruct = { x: BigNumberish; y: BigNumberish }; + +export type G1PointStructOutput = [x: bigint, y: bigint] & { + x: bigint; + y: bigint; +}; + +export type G2PointStruct = { + x: [BigNumberish, BigNumberish]; + y: [BigNumberish, BigNumberish]; +}; + +export type G2PointStructOutput = [x: [bigint, bigint], y: [bigint, bigint]] & { + x: [bigint, bigint]; + y: [bigint, bigint]; +}; + +export type SnarkProofStruct = { + a: G1PointStruct; + b: G2PointStruct; + c: G1PointStruct; +}; + +export type SnarkProofStructOutput = [ + a: G1PointStructOutput, + b: G2PointStructOutput, + c: G1PointStructOutput +] & { a: G1PointStructOutput; b: G2PointStructOutput; c: G1PointStructOutput }; + +export type CommitmentCiphertextStruct = { + ciphertext: [BytesLike, BytesLike, BytesLike, BytesLike]; + blindedSenderViewingKey: BytesLike; + blindedReceiverViewingKey: BytesLike; + annotationData: BytesLike; + memo: BytesLike; +}; + +export type CommitmentCiphertextStructOutput = [ + ciphertext: [string, string, string, string], + blindedSenderViewingKey: string, + blindedReceiverViewingKey: string, + annotationData: string, + memo: string +] & { + ciphertext: [string, string, string, string]; + blindedSenderViewingKey: string; + blindedReceiverViewingKey: string; + annotationData: string; + memo: string; +}; + +export type BoundParamsStruct = { + treeNumber: BigNumberish; + minGasPrice: BigNumberish; + unshield: BigNumberish; + chainID: BigNumberish; + adaptContract: AddressLike; + adaptParams: BytesLike; + commitmentCiphertext: CommitmentCiphertextStruct[]; +}; + +export type BoundParamsStructOutput = [ + treeNumber: bigint, + minGasPrice: bigint, + unshield: bigint, + chainID: bigint, + adaptContract: string, + adaptParams: string, + commitmentCiphertext: CommitmentCiphertextStructOutput[] +] & { + treeNumber: bigint; + minGasPrice: bigint; + unshield: bigint; + chainID: bigint; + adaptContract: string; + adaptParams: string; + commitmentCiphertext: CommitmentCiphertextStructOutput[]; +}; + +export type TokenDataStruct = { + tokenType: BigNumberish; + tokenAddress: AddressLike; + tokenSubID: BigNumberish; +}; + +export type TokenDataStructOutput = [ + tokenType: bigint, + tokenAddress: string, + tokenSubID: bigint +] & { tokenType: bigint; tokenAddress: string; tokenSubID: bigint }; + +export type CommitmentPreimageStruct = { + npk: BytesLike; + token: TokenDataStruct; + value: BigNumberish; +}; + +export type CommitmentPreimageStructOutput = [ + npk: string, + token: TokenDataStructOutput, + value: bigint +] & { npk: string; token: TokenDataStructOutput; value: bigint }; + +export type TransactionStruct = { + proof: SnarkProofStruct; + merkleRoot: BytesLike; + nullifiers: BytesLike[]; + commitments: BytesLike[]; + boundParams: BoundParamsStruct; + unshieldPreimage: CommitmentPreimageStruct; +}; + +export type TransactionStructOutput = [ + proof: SnarkProofStructOutput, + merkleRoot: string, + nullifiers: string[], + commitments: string[], + boundParams: BoundParamsStructOutput, + unshieldPreimage: CommitmentPreimageStructOutput +] & { + proof: SnarkProofStructOutput; + merkleRoot: string; + nullifiers: string[]; + commitments: string[]; + boundParams: BoundParamsStructOutput; + unshieldPreimage: CommitmentPreimageStructOutput; +}; + +export type ShieldCiphertextStruct = { + encryptedBundle: [BytesLike, BytesLike, BytesLike]; + shieldKey: BytesLike; +}; + +export type ShieldCiphertextStructOutput = [ + encryptedBundle: [string, string, string], + shieldKey: string +] & { encryptedBundle: [string, string, string]; shieldKey: string }; + +export type ShieldRequestStruct = { + preimage: CommitmentPreimageStruct; + ciphertext: ShieldCiphertextStruct; +}; + +export type ShieldRequestStructOutput = [ + preimage: CommitmentPreimageStructOutput, + ciphertext: ShieldCiphertextStructOutput +] & { + preimage: CommitmentPreimageStructOutput; + ciphertext: ShieldCiphertextStructOutput; +}; + +export declare namespace RelayAdapt7702 { + export type CallStruct = { + to: AddressLike; + data: BytesLike; + value: BigNumberish; + }; + + export type CallStructOutput = [to: string, data: string, value: bigint] & { + to: string; + data: string; + value: bigint; + }; + + export type ActionDataStruct = { + requireSuccess: boolean; + minGasLimit: BigNumberish; + calls: RelayAdapt7702.CallStruct[]; + }; + + export type ActionDataStructOutput = [ + requireSuccess: boolean, + minGasLimit: bigint, + calls: RelayAdapt7702.CallStructOutput[] + ] & { + requireSuccess: boolean; + minGasLimit: bigint; + calls: RelayAdapt7702.CallStructOutput[]; + }; + + export type TokenTransferStruct = { + token: TokenDataStruct; + to: AddressLike; + value: BigNumberish; + }; + + export type TokenTransferStructOutput = [ + token: TokenDataStructOutput, + to: string, + value: bigint + ] & { token: TokenDataStructOutput; to: string; value: bigint }; +} + +export interface RelayAdapt7702Interface extends Interface { + getFunction( + nameOrSignature: + | "CALL_TYPEHASH" + | "DOMAIN_SEPARATOR" + | "EXECUTE_TYPEHASH" + | "MULTICALL_TYPEHASH" + | "adaptImplementation" + | "execute" + | "getExecutePayloadHash" + | "multicall" + | "nonce" + | "railgun" + | "shield" + | "transfer" + | "unwrapBase" + | "wBase" + | "wrapBase" + ): FunctionFragment; + + getEvent(nameOrSignatureOrTopic: "CallError"): EventFragment; + + encodeFunctionData( + functionFragment: "CALL_TYPEHASH", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "DOMAIN_SEPARATOR", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "EXECUTE_TYPEHASH", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "MULTICALL_TYPEHASH", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "adaptImplementation", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "execute", + values: [TransactionStruct[], RelayAdapt7702.ActionDataStruct, BytesLike] + ): string; + encodeFunctionData( + functionFragment: "getExecutePayloadHash", + values: [TransactionStruct[], RelayAdapt7702.ActionDataStruct] + ): string; + encodeFunctionData( + functionFragment: "multicall", + values: [boolean, RelayAdapt7702.CallStruct[], BigNumberish, BytesLike] + ): string; + encodeFunctionData(functionFragment: "nonce", values?: undefined): string; + encodeFunctionData(functionFragment: "railgun", values?: undefined): string; + encodeFunctionData( + functionFragment: "shield", + values: [ShieldRequestStruct[]] + ): string; + encodeFunctionData( + functionFragment: "transfer", + values: [RelayAdapt7702.TokenTransferStruct[]] + ): string; + encodeFunctionData( + functionFragment: "unwrapBase", + values: [BigNumberish] + ): string; + encodeFunctionData(functionFragment: "wBase", values?: undefined): string; + encodeFunctionData( + functionFragment: "wrapBase", + values: [BigNumberish] + ): string; + + decodeFunctionResult( + functionFragment: "CALL_TYPEHASH", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "DOMAIN_SEPARATOR", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "EXECUTE_TYPEHASH", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "MULTICALL_TYPEHASH", + data: BytesLike + ): Result; + decodeFunctionResult( + functionFragment: "adaptImplementation", + data: BytesLike + ): Result; + decodeFunctionResult(functionFragment: "execute", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "getExecutePayloadHash", + data: BytesLike + ): Result; + decodeFunctionResult(functionFragment: "multicall", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "nonce", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "railgun", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "shield", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "transfer", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "unwrapBase", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "wBase", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "wrapBase", data: BytesLike): Result; +} + +export namespace CallErrorEvent { + export type InputTuple = [callIndex: BigNumberish, revertReason: BytesLike]; + export type OutputTuple = [callIndex: bigint, revertReason: string]; + export interface OutputObject { + callIndex: bigint; + revertReason: string; + } + export type Event = TypedContractEvent; + export type Filter = TypedDeferredTopicFilter; + export type Log = TypedEventLog; + export type LogDescription = TypedLogDescription; +} + +export interface RelayAdapt7702 extends BaseContract { + connect(runner?: ContractRunner | null): BaseContract; + attach(addressOrName: AddressLike): this; + deployed(): Promise; + + interface: RelayAdapt7702Interface; + + queryFilter( + event: TCEvent, + fromBlockOrBlockhash?: string | number | undefined, + toBlock?: string | number | undefined + ): Promise>>; + queryFilter( + filter: TypedDeferredTopicFilter, + fromBlockOrBlockhash?: string | number | undefined, + toBlock?: string | number | undefined + ): Promise>>; + + on( + event: TCEvent, + listener: TypedListener + ): Promise; + on( + filter: TypedDeferredTopicFilter, + listener: TypedListener + ): Promise; + + once( + event: TCEvent, + listener: TypedListener + ): Promise; + once( + filter: TypedDeferredTopicFilter, + listener: TypedListener + ): Promise; + + listeners( + event: TCEvent + ): Promise>>; + listeners(eventName?: string): Promise>; + removeAllListeners( + event?: TCEvent + ): Promise; + + CALL_TYPEHASH: TypedContractMethod<[], [string], "view">; + + DOMAIN_SEPARATOR: TypedContractMethod<[], [string], "view">; + + EXECUTE_TYPEHASH: TypedContractMethod<[], [string], "view">; + + MULTICALL_TYPEHASH: TypedContractMethod<[], [string], "view">; + + adaptImplementation: TypedContractMethod<[], [string], "view">; + + execute: TypedContractMethod< + [ + _transactions: TransactionStruct[], + _actionData: RelayAdapt7702.ActionDataStruct, + _signature: BytesLike + ], + [void], + "payable" + >; + + getExecutePayloadHash: TypedContractMethod< + [ + _transactions: TransactionStruct[], + _actionData: RelayAdapt7702.ActionDataStruct + ], + [string], + "view" + >; + + multicall: TypedContractMethod< + [ + _requireSuccess: boolean, + _calls: RelayAdapt7702.CallStruct[], + _nonce: BigNumberish, + _signature: BytesLike + ], + [void], + "payable" + >; + + nonce: TypedContractMethod<[], [bigint], "view">; + + railgun: TypedContractMethod<[], [string], "view">; + + shield: TypedContractMethod< + [_shieldRequests: ShieldRequestStruct[]], + [void], + "nonpayable" + >; + + transfer: TypedContractMethod< + [_transfers: RelayAdapt7702.TokenTransferStruct[]], + [void], + "nonpayable" + >; + + unwrapBase: TypedContractMethod< + [_amount: BigNumberish], + [void], + "nonpayable" + >; + + wBase: TypedContractMethod<[], [string], "view">; + + wrapBase: TypedContractMethod<[_amount: BigNumberish], [void], "nonpayable">; + + getFunction( + key: string | FunctionFragment + ): T; + + getFunction( + nameOrSignature: "CALL_TYPEHASH" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "DOMAIN_SEPARATOR" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "EXECUTE_TYPEHASH" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "MULTICALL_TYPEHASH" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "adaptImplementation" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "execute" + ): TypedContractMethod< + [ + _transactions: TransactionStruct[], + _actionData: RelayAdapt7702.ActionDataStruct, + _signature: BytesLike + ], + [void], + "payable" + >; + getFunction( + nameOrSignature: "getExecutePayloadHash" + ): TypedContractMethod< + [ + _transactions: TransactionStruct[], + _actionData: RelayAdapt7702.ActionDataStruct + ], + [string], + "view" + >; + getFunction( + nameOrSignature: "multicall" + ): TypedContractMethod< + [ + _requireSuccess: boolean, + _calls: RelayAdapt7702.CallStruct[], + _nonce: BigNumberish, + _signature: BytesLike + ], + [void], + "payable" + >; + getFunction( + nameOrSignature: "nonce" + ): TypedContractMethod<[], [bigint], "view">; + getFunction( + nameOrSignature: "railgun" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "shield" + ): TypedContractMethod< + [_shieldRequests: ShieldRequestStruct[]], + [void], + "nonpayable" + >; + getFunction( + nameOrSignature: "transfer" + ): TypedContractMethod< + [_transfers: RelayAdapt7702.TokenTransferStruct[]], + [void], + "nonpayable" + >; + getFunction( + nameOrSignature: "unwrapBase" + ): TypedContractMethod<[_amount: BigNumberish], [void], "nonpayable">; + getFunction( + nameOrSignature: "wBase" + ): TypedContractMethod<[], [string], "view">; + getFunction( + nameOrSignature: "wrapBase" + ): TypedContractMethod<[_amount: BigNumberish], [void], "nonpayable">; + + getEvent( + key: "CallError" + ): TypedContractEvent< + CallErrorEvent.InputTuple, + CallErrorEvent.OutputTuple, + CallErrorEvent.OutputObject + >; + + filters: { + "CallError(uint256,bytes)": TypedContractEvent< + CallErrorEvent.InputTuple, + CallErrorEvent.OutputTuple, + CallErrorEvent.OutputObject + >; + CallError: TypedContractEvent< + CallErrorEvent.InputTuple, + CallErrorEvent.OutputTuple, + CallErrorEvent.OutputObject + >; + }; +} diff --git a/src/abi/typechain/factories/Registry__factory.ts b/src/abi/typechain/factories/Registry__factory.ts new file mode 100644 index 00000000..b0b43479 --- /dev/null +++ b/src/abi/typechain/factories/Registry__factory.ts @@ -0,0 +1,363 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ + +import { Contract, Interface, type ContractRunner } from "ethers"; +import type { Registry, RegistryInterface } from "../Registry"; + +const _abi = [ + { + inputs: [ + { + internalType: "address", + name: "_owner", + type: "address", + }, + ], + stateMutability: "nonpayable", + type: "constructor", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "string", + name: "name", + type: "string", + }, + { + indexed: true, + internalType: "address", + name: "contractAddress", + type: "address", + }, + ], + name: "ContractDeprecated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "string", + name: "name", + type: "string", + }, + { + indexed: true, + internalType: "address", + name: "contractAddress", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "version", + type: "uint256", + }, + { + indexed: false, + internalType: "bool", + name: "deprecated", + type: "bool", + }, + ], + name: "ContractSet", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "previousOwner", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "newOwner", + type: "address", + }, + ], + name: "OwnershipTransferred", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "deployment", + type: "address", + }, + ], + name: "Registered", + type: "event", + }, + { + inputs: [ + { + internalType: "string", + name: "name", + type: "string", + }, + ], + name: "deprecateContract", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "getAllContracts", + outputs: [ + { + components: [ + { + internalType: "address", + name: "implementation", + type: "address", + }, + { + internalType: "uint256", + name: "version", + type: "uint256", + }, + { + internalType: "bool", + name: "deprecated", + type: "bool", + }, + { + internalType: "string", + name: "name", + type: "string", + }, + { + internalType: "uint256", + name: "nameIndex", + type: "uint256", + }, + ], + internalType: "struct Registry.RegistryEntry[]", + name: "", + type: "tuple[]", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "name", + type: "string", + }, + ], + name: "getContract", + outputs: [ + { + components: [ + { + internalType: "address", + name: "implementation", + type: "address", + }, + { + internalType: "uint256", + name: "version", + type: "uint256", + }, + { + internalType: "bool", + name: "deprecated", + type: "bool", + }, + { + internalType: "string", + name: "name", + type: "string", + }, + { + internalType: "uint256", + name: "nameIndex", + type: "uint256", + }, + ], + internalType: "struct Registry.RegistryEntry", + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "name", + type: "string", + }, + ], + name: "getContractAddress", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + name: "isDeployed", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + name: "nameIndex", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "owner", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + name: "registryIndex", + outputs: [ + { + internalType: "address", + name: "implementation", + type: "address", + }, + { + internalType: "uint256", + name: "version", + type: "uint256", + }, + { + internalType: "bool", + name: "deprecated", + type: "bool", + }, + { + internalType: "string", + name: "name", + type: "string", + }, + { + internalType: "uint256", + name: "nameIndex", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "renounceOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "name", + type: "string", + }, + { + internalType: "address", + name: "contractAddress", + type: "address", + }, + { + internalType: "uint256", + name: "version", + type: "uint256", + }, + ], + name: "setContract", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "newOwner", + type: "address", + }, + ], + name: "transferOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; + +export class Registry__factory { + static readonly abi = _abi; + static createInterface(): RegistryInterface { + return new Interface(_abi) as RegistryInterface; + } + static connect(address: string, runner?: ContractRunner | null): Registry { + return new Contract(address, _abi, runner) as unknown as Registry; + } +} diff --git a/src/abi/typechain/factories/RelayAdapt7702__factory.ts b/src/abi/typechain/factories/RelayAdapt7702__factory.ts new file mode 100644 index 00000000..ccf8dd23 --- /dev/null +++ b/src/abi/typechain/factories/RelayAdapt7702__factory.ts @@ -0,0 +1,854 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ + +import { Contract, Interface, type ContractRunner } from "ethers"; +import type { + RelayAdapt7702, + RelayAdapt7702Interface, +} from "../RelayAdapt7702"; + +const _abi = [ + { + inputs: [ + { + internalType: "address", + name: "_railgun", + type: "address", + }, + { + internalType: "address", + name: "_wBase", + type: "address", + }, + ], + stateMutability: "nonpayable", + type: "constructor", + }, + { + inputs: [ + { + internalType: "uint256", + name: "callIndex", + type: "uint256", + }, + { + internalType: "bytes", + name: "revertReason", + type: "bytes", + }, + ], + name: "CallFailed", + type: "error", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "uint256", + name: "callIndex", + type: "uint256", + }, + { + indexed: false, + internalType: "bytes", + name: "revertReason", + type: "bytes", + }, + ], + name: "CallError", + type: "event", + }, + { + inputs: [], + name: "CALL_TYPEHASH", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "DOMAIN_SEPARATOR", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "EXECUTE_TYPEHASH", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "MULTICALL_TYPEHASH", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "adaptImplementation", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + components: [ + { + components: [ + { + components: [ + { + internalType: "uint256", + name: "x", + type: "uint256", + }, + { + internalType: "uint256", + name: "y", + type: "uint256", + }, + ], + internalType: "struct G1Point", + name: "a", + type: "tuple", + }, + { + components: [ + { + internalType: "uint256[2]", + name: "x", + type: "uint256[2]", + }, + { + internalType: "uint256[2]", + name: "y", + type: "uint256[2]", + }, + ], + internalType: "struct G2Point", + name: "b", + type: "tuple", + }, + { + components: [ + { + internalType: "uint256", + name: "x", + type: "uint256", + }, + { + internalType: "uint256", + name: "y", + type: "uint256", + }, + ], + internalType: "struct G1Point", + name: "c", + type: "tuple", + }, + ], + internalType: "struct SnarkProof", + name: "proof", + type: "tuple", + }, + { + internalType: "bytes32", + name: "merkleRoot", + type: "bytes32", + }, + { + internalType: "bytes32[]", + name: "nullifiers", + type: "bytes32[]", + }, + { + internalType: "bytes32[]", + name: "commitments", + type: "bytes32[]", + }, + { + components: [ + { + internalType: "uint16", + name: "treeNumber", + type: "uint16", + }, + { + internalType: "uint72", + name: "minGasPrice", + type: "uint72", + }, + { + internalType: "enum UnshieldType", + name: "unshield", + type: "uint8", + }, + { + internalType: "uint64", + name: "chainID", + type: "uint64", + }, + { + internalType: "address", + name: "adaptContract", + type: "address", + }, + { + internalType: "bytes32", + name: "adaptParams", + type: "bytes32", + }, + { + components: [ + { + internalType: "bytes32[4]", + name: "ciphertext", + type: "bytes32[4]", + }, + { + internalType: "bytes32", + name: "blindedSenderViewingKey", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "blindedReceiverViewingKey", + type: "bytes32", + }, + { + internalType: "bytes", + name: "annotationData", + type: "bytes", + }, + { + internalType: "bytes", + name: "memo", + type: "bytes", + }, + ], + internalType: "struct CommitmentCiphertext[]", + name: "commitmentCiphertext", + type: "tuple[]", + }, + ], + internalType: "struct BoundParams", + name: "boundParams", + type: "tuple", + }, + { + components: [ + { + internalType: "bytes32", + name: "npk", + type: "bytes32", + }, + { + components: [ + { + internalType: "enum TokenType", + name: "tokenType", + type: "uint8", + }, + { + internalType: "address", + name: "tokenAddress", + type: "address", + }, + { + internalType: "uint256", + name: "tokenSubID", + type: "uint256", + }, + ], + internalType: "struct TokenData", + name: "token", + type: "tuple", + }, + { + internalType: "uint120", + name: "value", + type: "uint120", + }, + ], + internalType: "struct CommitmentPreimage", + name: "unshieldPreimage", + type: "tuple", + }, + ], + internalType: "struct Transaction[]", + name: "_transactions", + type: "tuple[]", + }, + { + components: [ + { + internalType: "bool", + name: "requireSuccess", + type: "bool", + }, + { + internalType: "uint256", + name: "minGasLimit", + type: "uint256", + }, + { + components: [ + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + internalType: "struct RelayAdapt7702.Call[]", + name: "calls", + type: "tuple[]", + }, + ], + internalType: "struct RelayAdapt7702.ActionData", + name: "_actionData", + type: "tuple", + }, + { + internalType: "bytes", + name: "_signature", + type: "bytes", + }, + ], + name: "execute", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + components: [ + { + components: [ + { + components: [ + { + internalType: "uint256", + name: "x", + type: "uint256", + }, + { + internalType: "uint256", + name: "y", + type: "uint256", + }, + ], + internalType: "struct G1Point", + name: "a", + type: "tuple", + }, + { + components: [ + { + internalType: "uint256[2]", + name: "x", + type: "uint256[2]", + }, + { + internalType: "uint256[2]", + name: "y", + type: "uint256[2]", + }, + ], + internalType: "struct G2Point", + name: "b", + type: "tuple", + }, + { + components: [ + { + internalType: "uint256", + name: "x", + type: "uint256", + }, + { + internalType: "uint256", + name: "y", + type: "uint256", + }, + ], + internalType: "struct G1Point", + name: "c", + type: "tuple", + }, + ], + internalType: "struct SnarkProof", + name: "proof", + type: "tuple", + }, + { + internalType: "bytes32", + name: "merkleRoot", + type: "bytes32", + }, + { + internalType: "bytes32[]", + name: "nullifiers", + type: "bytes32[]", + }, + { + internalType: "bytes32[]", + name: "commitments", + type: "bytes32[]", + }, + { + components: [ + { + internalType: "uint16", + name: "treeNumber", + type: "uint16", + }, + { + internalType: "uint72", + name: "minGasPrice", + type: "uint72", + }, + { + internalType: "enum UnshieldType", + name: "unshield", + type: "uint8", + }, + { + internalType: "uint64", + name: "chainID", + type: "uint64", + }, + { + internalType: "address", + name: "adaptContract", + type: "address", + }, + { + internalType: "bytes32", + name: "adaptParams", + type: "bytes32", + }, + { + components: [ + { + internalType: "bytes32[4]", + name: "ciphertext", + type: "bytes32[4]", + }, + { + internalType: "bytes32", + name: "blindedSenderViewingKey", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "blindedReceiverViewingKey", + type: "bytes32", + }, + { + internalType: "bytes", + name: "annotationData", + type: "bytes", + }, + { + internalType: "bytes", + name: "memo", + type: "bytes", + }, + ], + internalType: "struct CommitmentCiphertext[]", + name: "commitmentCiphertext", + type: "tuple[]", + }, + ], + internalType: "struct BoundParams", + name: "boundParams", + type: "tuple", + }, + { + components: [ + { + internalType: "bytes32", + name: "npk", + type: "bytes32", + }, + { + components: [ + { + internalType: "enum TokenType", + name: "tokenType", + type: "uint8", + }, + { + internalType: "address", + name: "tokenAddress", + type: "address", + }, + { + internalType: "uint256", + name: "tokenSubID", + type: "uint256", + }, + ], + internalType: "struct TokenData", + name: "token", + type: "tuple", + }, + { + internalType: "uint120", + name: "value", + type: "uint120", + }, + ], + internalType: "struct CommitmentPreimage", + name: "unshieldPreimage", + type: "tuple", + }, + ], + internalType: "struct Transaction[]", + name: "_transactions", + type: "tuple[]", + }, + { + components: [ + { + internalType: "bool", + name: "requireSuccess", + type: "bool", + }, + { + internalType: "uint256", + name: "minGasLimit", + type: "uint256", + }, + { + components: [ + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + internalType: "struct RelayAdapt7702.Call[]", + name: "calls", + type: "tuple[]", + }, + ], + internalType: "struct RelayAdapt7702.ActionData", + name: "_actionData", + type: "tuple", + }, + ], + name: "getExecutePayloadHash", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "pure", + type: "function", + }, + { + inputs: [ + { + internalType: "bool", + name: "_requireSuccess", + type: "bool", + }, + { + components: [ + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + internalType: "struct RelayAdapt7702.Call[]", + name: "_calls", + type: "tuple[]", + }, + { + internalType: "uint256", + name: "_nonce", + type: "uint256", + }, + { + internalType: "bytes", + name: "_signature", + type: "bytes", + }, + ], + name: "multicall", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [], + name: "nonce", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "railgun", + outputs: [ + { + internalType: "contract RailgunSmartWallet", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + components: [ + { + components: [ + { + internalType: "bytes32", + name: "npk", + type: "bytes32", + }, + { + components: [ + { + internalType: "enum TokenType", + name: "tokenType", + type: "uint8", + }, + { + internalType: "address", + name: "tokenAddress", + type: "address", + }, + { + internalType: "uint256", + name: "tokenSubID", + type: "uint256", + }, + ], + internalType: "struct TokenData", + name: "token", + type: "tuple", + }, + { + internalType: "uint120", + name: "value", + type: "uint120", + }, + ], + internalType: "struct CommitmentPreimage", + name: "preimage", + type: "tuple", + }, + { + components: [ + { + internalType: "bytes32[3]", + name: "encryptedBundle", + type: "bytes32[3]", + }, + { + internalType: "bytes32", + name: "shieldKey", + type: "bytes32", + }, + ], + internalType: "struct ShieldCiphertext", + name: "ciphertext", + type: "tuple", + }, + ], + internalType: "struct ShieldRequest[]", + name: "_shieldRequests", + type: "tuple[]", + }, + ], + name: "shield", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + components: [ + { + components: [ + { + internalType: "enum TokenType", + name: "tokenType", + type: "uint8", + }, + { + internalType: "address", + name: "tokenAddress", + type: "address", + }, + { + internalType: "uint256", + name: "tokenSubID", + type: "uint256", + }, + ], + internalType: "struct TokenData", + name: "token", + type: "tuple", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + internalType: "struct RelayAdapt7702.TokenTransfer[]", + name: "_transfers", + type: "tuple[]", + }, + ], + name: "transfer", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "_amount", + type: "uint256", + }, + ], + name: "unwrapBase", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "wBase", + outputs: [ + { + internalType: "contract IWBase", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "_amount", + type: "uint256", + }, + ], + name: "wrapBase", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + stateMutability: "payable", + type: "receive", + }, +] as const; + +export class RelayAdapt7702__factory { + static readonly abi = _abi; + static createInterface(): RelayAdapt7702Interface { + return new Interface(_abi) as RelayAdapt7702Interface; + } + static connect( + address: string, + runner?: ContractRunner | null + ): RelayAdapt7702 { + return new Contract(address, _abi, runner) as unknown as RelayAdapt7702; + } +} diff --git a/src/abi/typechain/factories/index.ts b/src/abi/typechain/factories/index.ts index 47b4a4b0..5a21a93c 100644 --- a/src/abi/typechain/factories/index.ts +++ b/src/abi/typechain/factories/index.ts @@ -3,4 +3,8 @@ /* eslint-disable */ export { PoseidonMerkleAccumulator__factory } from "./PoseidonMerkleAccumulator__factory"; export { PoseidonMerkleVerifier__factory } from "./PoseidonMerkleVerifier__factory"; +export { RailgunSmartWallet_Legacy_PreMar23__factory } from "./RailgunSmartWallet_Legacy_PreMar23__factory"; +export { Registry__factory } from "./Registry__factory"; +export { RelayAdapt__factory } from "./RelayAdapt__factory"; +export { RelayAdapt7702__factory } from "./RelayAdapt7702__factory"; export { TokenVault__factory } from "./TokenVault__factory"; diff --git a/src/abi/typechain/index.ts b/src/abi/typechain/index.ts index 2bfacb02..33293774 100644 --- a/src/abi/typechain/index.ts +++ b/src/abi/typechain/index.ts @@ -3,8 +3,16 @@ /* eslint-disable */ export type { PoseidonMerkleAccumulator } from "./PoseidonMerkleAccumulator"; export type { PoseidonMerkleVerifier } from "./PoseidonMerkleVerifier"; +export type { RailgunSmartWallet_Legacy_PreMar23 } from "./RailgunSmartWallet_Legacy_PreMar23"; +export type { Registry } from "./Registry"; +export type { RelayAdapt } from "./RelayAdapt"; +export type { RelayAdapt7702 } from "./RelayAdapt7702"; export type { TokenVault } from "./TokenVault"; export * as factories from "./factories"; export { PoseidonMerkleAccumulator__factory } from "./factories/PoseidonMerkleAccumulator__factory"; export { PoseidonMerkleVerifier__factory } from "./factories/PoseidonMerkleVerifier__factory"; +export { RailgunSmartWallet_Legacy_PreMar23__factory } from "./factories/RailgunSmartWallet_Legacy_PreMar23__factory"; +export { Registry__factory } from "./factories/Registry__factory"; +export { RelayAdapt__factory } from "./factories/RelayAdapt__factory"; +export { RelayAdapt7702__factory } from "./factories/RelayAdapt7702__factory"; export { TokenVault__factory } from "./factories/TokenVault__factory"; diff --git a/src/contracts/contract-store.ts b/src/contracts/contract-store.ts index b1711f75..cd827931 100644 --- a/src/contracts/contract-store.ts +++ b/src/contracts/contract-store.ts @@ -5,6 +5,8 @@ import { PoseidonMerkleAccumulatorContract } from './railgun-smart-wallet/V3/pos import { PoseidonMerkleVerifierContract } from './railgun-smart-wallet/V3/poseidon-merkle-verifier'; import { TokenVaultContract } from './railgun-smart-wallet/V3/token-vault-contract'; import { RelayAdaptV2Contract } from './relay-adapt/V2/relay-adapt-v2'; +import { RelayAdapt7702Contract } from './relay-adapt/V2/relay-adapt-7702'; +import { RegistryContract } from './relay-adapt/V2/registry'; import { RelayAdaptV3Contract } from './relay-adapt/V3/relay-adapt-v3'; export class ContractStore { @@ -13,6 +15,10 @@ export class ContractStore { static readonly relayAdaptV2Contracts: Registry = new Registry(); + static readonly relayAdapt7702Contracts: Registry = new Registry(); + + static readonly railgunRegistryContract: Registry = new Registry(); + static readonly poseidonMerkleAccumulatorV3Contracts: Registry = new Registry(); diff --git a/src/contracts/railgun-smart-wallet/railgun-versioned-smart-contracts.ts b/src/contracts/railgun-smart-wallet/railgun-versioned-smart-contracts.ts index 024c232d..7e38ae56 100644 --- a/src/contracts/railgun-smart-wallet/railgun-versioned-smart-contracts.ts +++ b/src/contracts/railgun-smart-wallet/railgun-versioned-smart-contracts.ts @@ -60,9 +60,12 @@ export class RailgunVersionedSmartContracts { throw new Error('Unsupported txidVersion'); } - static getRelayAdaptContract(txidVersion: TXIDVersion, chain: Chain) { + static getRelayAdaptContract(txidVersion: TXIDVersion, chain: Chain, useRelayAdapt7702: boolean = false) { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: { + if(useRelayAdapt7702) { + return ContractStore.relayAdapt7702Contracts.getOrThrow(null, chain); + } return ContractStore.relayAdaptV2Contracts.getOrThrow(null, chain); } case TXIDVersion.V3_PoseidonMerkle: { diff --git a/src/contracts/relay-adapt/V2/registry.ts b/src/contracts/relay-adapt/V2/registry.ts new file mode 100644 index 00000000..ad57e49d --- /dev/null +++ b/src/contracts/relay-adapt/V2/registry.ts @@ -0,0 +1,29 @@ +import { Contract, Provider } from 'ethers'; +import { ABIRegistry } from '../../../abi/abi'; +import { Registry } from '../../../abi/typechain/Registry'; + +export const REGISTRY_NAME_RELAY_ADAPT_7702 = 'RelayAdapt7702'; + +export class RegistryContract { + private readonly contract: Registry; + + readonly address: string; + + /** + * Connect to Registry + * @param deployerAddress - address of Registry + * @param provider - Network provider + */ + constructor(deployerAddress: string, provider: Provider) { + this.address = deployerAddress; + this.contract = new Contract( + deployerAddress, + ABIRegistry, + provider, + ) as unknown as Registry; + } + + async getContractAddress(contractName: string): Promise { + return this.contract.getContractAddress(contractName); + } +} diff --git a/src/contracts/relay-adapt/V2/relay-adapt-7702.ts b/src/contracts/relay-adapt/V2/relay-adapt-7702.ts new file mode 100644 index 00000000..82de098b --- /dev/null +++ b/src/contracts/relay-adapt/V2/relay-adapt-7702.ts @@ -0,0 +1,488 @@ +import { + AbiCoder, + Contract, + ContractTransaction, + Provider, + Interface, + TransactionRequest, + Result, + Log, + toUtf8String, + Authorization, +} from 'ethers'; +import { ABIRelayAdapt, ABIRelayAdapt7702 } from '../../../abi/abi'; +import { TransactionReceiptLog } from '../../../models/formatted-types'; +import { getTokenDataERC20 } from '../../../note/note-util'; +import { ZERO_ADDRESS } from '../../../utils/constants'; +import { RelayAdapt7702Helper } from '../relay-adapt-7702-helper'; +import EngineDebug from '../../../debugger/debugger'; +import { ShieldRequestStruct } from '../../../abi/typechain/RailgunSmartWallet'; +import { RelayAdapt } from '../../../abi/typechain/RelayAdapt'; +import { RelayAdapt7702 } from '../../../abi/typechain/RelayAdapt7702'; +import { PayableOverrides } from '../../../abi/typechain/common'; +import { TransactionStructV2 } from '../../../models/transaction-types'; +import { MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2 } from '../constants'; + +enum RelayAdaptEvent { + CallError = 'CallError', +} + +export const RETURN_DATA_RELAY_ADAPT_STRING_PREFIX = '0x5c0dee5d'; +export const RETURN_DATA_STRING_PREFIX = '0x08c379a0'; + +export class RelayAdapt7702Contract { + private readonly contract: RelayAdapt; + + readonly address: string; + + /** + * Connect to Railgun instance on network + * @param relayAdaptV2ContractAddress - address of Railgun relay adapt contract + * @param provider - Network provider + */ + constructor(relayAdaptV2ContractAddress: string, provider: Provider) { + this.address = relayAdaptV2ContractAddress; + this.contract = new Contract( + relayAdaptV2ContractAddress, + ABIRelayAdapt7702, + provider, + ) as unknown as RelayAdapt; + } + + async populateShieldBaseToken( + shieldRequest: ShieldRequestStruct, + authorization?: Authorization, + signature?: string, + random31Bytes?: string, + ephemeralAddress?: string, + ): Promise { + void random31Bytes; + + const orderedCalls: ContractTransaction[] = await this.getOrderedCallsForShieldBaseToken( + shieldRequest, + ephemeralAddress, + ); + return this.populateRelayMulticall( + orderedCalls, + { + value: shieldRequest.preimage.value, + }, + authorization, + signature, + ephemeralAddress, + ); + } + + async getOrderedCallsForShieldBaseToken( + shieldRequest: ShieldRequestStruct, + ephemeralAddress?: string, + ): Promise { + const calls = await Promise.all([ + this.contract.wrapBase.populateTransaction(shieldRequest.preimage.value), + this.populateRelayShields([shieldRequest]), + ]); + if (ephemeralAddress) { + calls.forEach((call) => { + if (call.to === this.address) { + // eslint-disable-next-line no-param-reassign + call.to = ephemeralAddress; + } + }); + } + return calls; + } + + async populateMulticall( + calls: ContractTransaction[], + shieldRequests: ShieldRequestStruct[], + ): Promise { + const orderedCalls = await this.getOrderedCallsForCrossContractCalls(calls, shieldRequests); + return this.populateRelayMulticall(orderedCalls, {}); + } + + /** + * @returns Populated transaction + */ + private populateRelayShields( + shieldRequests: ShieldRequestStruct[], + ): Promise { + return this.contract.shield.populateTransaction(shieldRequests); + } + + async getOrderedCallsForUnshieldBaseToken( + unshieldAddress: string, + ephemeralAddress?: string, + ): Promise { + // Use 0x00 address ERC20 to represent base token. + const baseTokenData = getTokenDataERC20(ZERO_ADDRESS); + + // Automatically unwraps and unshields all tokens. + const value = 0n; + + const baseTokenTransfer: RelayAdapt.TokenTransferStruct = { + token: baseTokenData, + to: unshieldAddress, + value, + }; + + const calls = await Promise.all([ + this.contract.unwrapBase.populateTransaction(value), + this.populateRelayTransfers([baseTokenTransfer]), + ]); + if (ephemeralAddress) { + calls.forEach((call) => { + if (call.to === this.address) { + // eslint-disable-next-line no-param-reassign + call.to = ephemeralAddress; + } + }); + } + return calls; + } + + async getRelayAdaptParamsUnshieldBaseToken( + dummyTransactions: TransactionStructV2[], + unshieldAddress: string, + random: string, + sendWithPublicWallet: boolean, + ephemeralAddress?: string, + ): Promise { + void dummyTransactions; + void unshieldAddress; + void random; + void sendWithPublicWallet; + void ephemeralAddress; + + // 7702 binds execution via signature rather than proof adapt params. + return RelayAdapt7702Helper.getZeroAdaptParams(); + } + + async populateUnshieldBaseToken( + transactions: TransactionStructV2[], + unshieldAddress: string, + random31Bytes: string, + _useDummyProof: boolean, + sendWithPublicWallet: boolean, + authorization?: Authorization, + signature?: string, + ephemeralAddress?: string, + ): Promise { + const orderedCalls: ContractTransaction[] = await this.getOrderedCallsForUnshieldBaseToken( + unshieldAddress, + ephemeralAddress, + ); + void random31Bytes; + + const requireSuccess = sendWithPublicWallet; + return this.populateRelay( + transactions, + requireSuccess, + orderedCalls, + {}, + BigInt(0), + authorization, + signature, + ephemeralAddress, + ); + } + + /** + * @returns Populated transaction + */ + private populateRelayTransfers( + transfersData: RelayAdapt.TokenTransferStruct[], + ): Promise { + return this.contract.transfer.populateTransaction(transfersData); + } + + async getOrderedCallsForCrossContractCalls( + crossContractCalls: ContractTransaction[], + relayShieldRequests: ShieldRequestStruct[], + ): Promise { + const orderedCallPromises: ContractTransaction[] = [...crossContractCalls]; + if (relayShieldRequests.length) { + orderedCallPromises.push(await this.populateRelayShields(relayShieldRequests)); + } + return orderedCallPromises; + } + + private static shouldRequireSuccessForCrossContractCalls( + isGasEstimate: boolean, + isBroadcasterTransaction: boolean, + ): boolean { + // If the cross contract calls (multicalls) fail, the Broadcaster Fee and Shields should continue to process. + // We should only !requireSuccess for production broadcaster transactions (not gas estimates). + const continueAfterMulticallFailure = isBroadcasterTransaction && !isGasEstimate; + return !continueAfterMulticallFailure; + } + + async getRelayAdaptParamsCrossContractCalls( + dummyUnshieldTransactions: TransactionStructV2[], + crossContractCalls: ContractTransaction[], + relayShieldRequests: ShieldRequestStruct[], + random: string, + isBroadcasterTransaction: boolean, + minGasLimit?: bigint, + ephemeralAddress?: string, + ): Promise { + void dummyUnshieldTransactions; + void crossContractCalls; + void relayShieldRequests; + void random; + void isBroadcasterTransaction; + void minGasLimit; + void ephemeralAddress; + + // 7702 binds execution via signature rather than proof adapt params. + return RelayAdapt7702Helper.getZeroAdaptParams(); + } + + async populateCrossContractCalls( + unshieldTransactions: TransactionStructV2[], + crossContractCalls: ContractTransaction[], + relayShieldRequests: ShieldRequestStruct[], + random31Bytes: string, + isGasEstimate: boolean, + isBroadcasterTransaction: boolean, + minGasLimit?: bigint, + authorization?: Authorization, + signature?: string, + ephemeralAddress?: string, + ): Promise { + const orderedCalls: ContractTransaction[] = await this.getOrderedCallsForCrossContractCalls( + crossContractCalls, + relayShieldRequests, + ); + void random31Bytes; + + if (ephemeralAddress) { + orderedCalls.forEach((call) => { + if (call.to === this.address) { + // eslint-disable-next-line no-param-reassign + call.to = ephemeralAddress; + } + }); + } + + const requireSuccess = RelayAdapt7702Contract.shouldRequireSuccessForCrossContractCalls( + isGasEstimate, + isBroadcasterTransaction, + ); + + const minimumGasLimit = minGasLimit ?? MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2; + const minGasLimitForContract = + RelayAdapt7702Contract.getMinimumGasLimitForContract(minimumGasLimit); + + const populatedTransaction = await this.populateRelay( + unshieldTransactions, + requireSuccess, + orderedCalls, + {}, + minGasLimitForContract, + authorization, + signature, + ephemeralAddress, + ); + + // Set default gas limit for cross-contract calls. + populatedTransaction.gasLimit = minimumGasLimit; + + return populatedTransaction; + } + + static getMinimumGasLimitForContract(minimumGasLimit: bigint) { + // Contract call needs ~50,000-150,000 less gas than the gasLimit setting. + // This can be more if there are complex UTXO sets for the unshield. + return minimumGasLimit - 150_000n; + } + + static async estimateGasWithErrorHandler( + provider: Provider, + transaction: ContractTransaction | TransactionRequest, + ): Promise { + try { + const gasEstimate = await provider.estimateGas(transaction); + return gasEstimate; + } catch (cause) { + if (!(cause instanceof Error)) { + throw new Error('Non-error thrown from estimateGas', { cause }); + } + const { callFailedIndexString, errorMessage } = + RelayAdapt7702Contract.extractGasEstimateCallFailedIndexAndErrorText(cause.message); + throw new Error(`RelayAdapt multicall failed at index ${callFailedIndexString}.`, { + cause: new Error(errorMessage), + }); + } + } + + static extractGasEstimateCallFailedIndexAndErrorText(errorMessage: string) { + try { + // Sample error text from ethers v6.4.0: 'execution reverted (unknown custom error) (action="estimateGas", data="0x5c0dee5d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", reason=null, transaction={ "data": "0x28223a77000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000007a00000000000000000000000000000000000000000000000000…00000000004640cd6086ade3e984b011b4e8c7cab9369b90499ab88222e673ec1ae4d2c3bf78ae96e95f9171653e5b1410273269edd64a0ab792a5d355093caa9cb92406125c7803a48028503783f2ab5e84f0ea270ce770860e436b77c942ed904a5d577d021cf0fd936183e0298175679d63d73902e116484e10c7b558d4dc84e113380500000000000000000000000000000000000000000000000000000000", "from": "0x000000000000000000000000000000000000dEaD", "to": "0x0355B7B8cb128fA5692729Ab3AAa199C1753f726" }, invocation=null, revert=null, code=CALL_EXCEPTION, version=6.4.0)' + const prefixSplit = ` (action="estimateGas", data="`; + const splitResult = errorMessage.split(prefixSplit); + const callFailedMessage = splitResult[0]; // execution reverted (unknown custom error) + const dataMessage = splitResult[1].split(`"`)[0]; // 0x5c0dee5d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000 + const parsedDataMessage = this.parseRelayAdaptReturnValue(dataMessage); + const callFailedIndexString: string = parsedDataMessage?.callIndex?.toString() ?? 'UNKNOWN'; + return { + callFailedIndexString, + errorMessage: `'${callFailedMessage}': ${parsedDataMessage?.error ?? dataMessage}`, + }; + } catch (err) { + return { + callFailedIndexString: 'UNKNOWN', + errorMessage, + }; + } + } + + /** + * Generates Relay multicall given a list of ordered calls. + * @returns populated transaction + */ + private async populateRelayMulticall( + calls: ContractTransaction[], + overrides: PayableOverrides, + authorization?: Authorization, + signature?: string, + ephemeralAddress?: string, + ): Promise { + // Always requireSuccess when there is no Broadcaster payment. + const requireSuccess = true; + + // Use empty transactions for pure multicall + const transactions: TransactionStructV2[] = []; + + return this.populateRelay( + transactions, + requireSuccess, + calls, + overrides, + BigInt(0), + authorization, + signature, + ephemeralAddress, + ); + } + + /** + * Generates Relay multicall given a list of transactions and ordered calls. + * @returns populated transaction + */ + private async populateRelay( + transactions: TransactionStructV2[], + requireSuccess: boolean, + calls: ContractTransaction[], + overrides: PayableOverrides, + minimumGasLimit = BigInt(0), + authorization?: Authorization, + signature?: string, + ephemeralAddress?: string, + ): Promise { + const actionData: RelayAdapt7702.ActionDataStruct = RelayAdapt7702Helper.getActionData( + requireSuccess, + calls, + minimumGasLimit, + ); + + const sig = signature ?? '0x'; + + const populatedTransaction = await (this.contract as any).execute.populateTransaction( + transactions, + actionData, + sig, + overrides, + ); + + if (ephemeralAddress) { + populatedTransaction.to = ephemeralAddress; + } + + if (authorization) { + (populatedTransaction as any).authorizationList = [authorization]; + } + + return populatedTransaction; + } + + private static getCallErrorTopic() { + const iface = new Interface(ABIRelayAdapt); + return iface.encodeFilterTopics(RelayAdaptEvent.CallError, [])[0]; + } + + static getRelayAdaptCallError( + receiptLogs: TransactionReceiptLog[] | readonly Log[], + ): Optional { + const topic = this.getCallErrorTopic(); + try { + for (const log of receiptLogs) { + if (log.topics[0] === topic) { + const parsed = this.customRelayAdaptErrorParse(log.data); + if (parsed) { + return parsed.error; + } + } + } + } catch (cause) { + if (!(cause instanceof Error)) { + throw new Error('Non-error thrown from getRelayAdaptCallError.', { cause }); + } + const err = new Error('Relay Adapt log parsing error', { cause }); + EngineDebug.error(err); + throw err; + } + return undefined; + } + + static parseRelayAdaptReturnValue( + returnValue: string, + ): Optional<{ callIndex?: number; error: string }> { + if (returnValue.match(RETURN_DATA_RELAY_ADAPT_STRING_PREFIX)) { + const strippedReturnValue = returnValue.replace(RETURN_DATA_RELAY_ADAPT_STRING_PREFIX, '0x'); + return this.customRelayAdaptErrorParse(strippedReturnValue); + } + if (returnValue.match(RETURN_DATA_STRING_PREFIX)) { + return { error: this.parseRelayAdaptStringError(returnValue) }; + } + return { + error: `Not a RelayAdapt return value: must be prefixed with ${RETURN_DATA_RELAY_ADAPT_STRING_PREFIX} or ${RETURN_DATA_STRING_PREFIX}`, + }; + } + + private static customRelayAdaptErrorParse( + data: string, + ): Optional<{ callIndex: number; error: string }> { + // Force parse as bytes + const decoded: Result = AbiCoder.defaultAbiCoder().decode( + ['uint256 callIndex', 'bytes revertReason'], + data, + ); + + const callIndex = Number(decoded[0]); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const revertReasonBytes: string = decoded[1]; + + // Map function to try parsing bytes as string + const error = this.parseRelayAdaptStringError(revertReasonBytes); + return { callIndex, error }; + } + + private static parseRelayAdaptStringError(revertReason: string): string { + if (revertReason.match(RETURN_DATA_STRING_PREFIX)) { + const strippedReturnValue = revertReason.replace(RETURN_DATA_STRING_PREFIX, '0x'); + const result = AbiCoder.defaultAbiCoder().decode(['string'], strippedReturnValue); + return result[0]; + } + try { + const utf8 = toUtf8String(revertReason); + if (utf8.length === 0) { + throw new Error('No utf8 string parsed from revert reason.'); + } + return utf8; + } catch (err) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `Unknown Relay Adapt error: ${err?.message ?? err}`; + } + } +} diff --git a/src/contracts/relay-adapt/__tests__/relay-adapt-7702.test.ts b/src/contracts/relay-adapt/__tests__/relay-adapt-7702.test.ts new file mode 100644 index 00000000..8b9841c0 --- /dev/null +++ b/src/contracts/relay-adapt/__tests__/relay-adapt-7702.test.ts @@ -0,0 +1,1979 @@ +/* eslint-disable prefer-template */ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import memdown from 'memdown'; +import { groth16 } from 'snarkjs'; +import { bytesToHex } from 'ethereum-cryptography/utils'; +import { + Contract, + ContractTransaction, + FallbackProvider, + JsonRpcProvider, + TransactionReceipt, + Wallet, +} from 'ethers'; +import { RelayAdapt7702Helper } from '../relay-adapt-7702-helper'; +import { RelayAdapt7702 } from '../../../abi/typechain/RelayAdapt7702'; +import { deriveEphemeralWallet } from '../../../key-derivation/ephemeral-key'; +import { abi as erc20Abi } from '../../../test/test-erc20-abi.test'; +import { abi as erc721Abi } from '../../../test/test-erc721-abi.test'; +import { config } from '../../../test/config.test'; +import { RailgunWallet } from '../../../wallet/railgun-wallet'; +import { + awaitMultipleScans, + awaitRailgunSmartWalletShield, + awaitRailgunSmartWalletTransact, + awaitRailgunSmartWalletUnshield, + awaitScan, + getEthersWallet, + getTestTXIDVersion, + isV2Test, + mockGetLatestValidatedRailgunTxid, + mockQuickSyncEvents, + mockQuickSyncRailgunTransactionsV2, + mockRailgunTxidMerklerootValidator, + sendTransactionWithLatestNonce, + testArtifactsGetter, +} from '../../../test/helper.test'; +import { + NFTTokenData, + OutputType, + RelayAdaptShieldERC20Recipient, +} from '../../../models/formatted-types'; +import { ByteLength, ByteUtils } from '../../../utils/bytes'; +import { SnarkJSGroth16 } from '../../../prover/prover'; +import { Chain, ChainType } from '../../../models/engine-types'; +import { RailgunEngine } from '../../../railgun-engine'; +import { RelayAdapt7702Contract } from '../V2/relay-adapt-7702'; +import { ShieldNoteERC20 } from '../../../note/erc20/shield-note-erc20'; +import { TransactNote } from '../../../note/transact-note'; +import { UnshieldNoteERC20 } from '../../../note/erc20/unshield-note-erc20'; +import { TransactionBatch } from '../../../transaction/transaction-batch'; +import { getTokenDataERC20 } from '../../../note/note-util'; +import { mintNFTsID01ForTest, shieldNFTForTest } from '../../../test/shared-test.test'; +import { UnshieldNoteNFT } from '../../../note'; +import FormattedRelayAdaptErrorLogs from './json/formatted-relay-adapt-error-logs.json'; +import { TestERC721 } from '../../../test/abi/typechain/TestERC721'; +import { TestERC20 } from '../../../test/abi/typechain/TestERC20'; +import { PollingJsonRpcProvider } from '../../../provider/polling-json-rpc-provider'; +import { promiseTimeout } from '../../../utils/promises'; +import { createPollingJsonRpcProviderForListeners } from '../../../provider/polling-util'; +import { isDefined } from '../../../utils/is-defined'; +import { TXIDVersion } from '../../../models/poi-types'; +import { WalletBalanceBucket } from '../../../models/txo-types'; +import { RailgunVersionedSmartContracts } from '../../railgun-smart-wallet/railgun-versioned-smart-contracts'; +import { RelayAdaptVersionedSmartContracts } from '../relay-adapt-versioned-smart-contracts'; +import { TransactionStructV2 } from '../../../models'; +import { MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2 } from '../constants'; + +chai.use(chaiAsPromised); +const { expect } = chai; + +let provider: JsonRpcProvider; +let chain: Chain; +let engine: RailgunEngine; +let ethersWallet: Wallet; +let snapshot: number; +let nft: TestERC721; +let wallet: RailgunWallet; +let wallet2: RailgunWallet; + +const txidVersion = getTestTXIDVersion(); + +const testMnemonic = config.mnemonic; +const testEncryptionKey = config.encryptionKey; + +const WETH_TOKEN_ADDRESS = config.contracts.weth9; +const SHIELD_RANDOM = ByteUtils.randomHex(16); + +const NFT_ADDRESS = config.contracts.testERC721; + +const wethTokenData = getTokenDataERC20(WETH_TOKEN_ADDRESS); + +const DEAD_ADDRESS = '0x000000000000000000000000000000000000dEaD'; +const DEPLOYMENT_BLOCKS = { + [TXIDVersion.V2_PoseidonMerkle]: isDefined(process.env.DEPLOYMENT_BLOCK) + ? Number(process.env.DEPLOYMENT_BLOCK) + : 0, + [TXIDVersion.V3_PoseidonMerkle]: isDefined(process.env.DEPLOYMENT_BLOCK) + ? Number(process.env.DEPLOYMENT_BLOCK) + : 0, +}; + +let testShieldBaseToken: (value?: bigint) => Promise; + +describe('relay-adapt-7702', function test() { + this.timeout(45_000); + + beforeEach(async () => { + engine = await RailgunEngine.initForWallet( + 'TestRelayAdapt', + memdown(), + testArtifactsGetter, + mockQuickSyncEvents, + mockQuickSyncRailgunTransactionsV2, + mockRailgunTxidMerklerootValidator, + mockGetLatestValidatedRailgunTxid, + undefined, // engineDebugger + undefined, // skipMerkletreeScans + ); + + engine.prover.setSnarkJSGroth16(groth16 as SnarkJSGroth16); + + wallet = await engine.createWalletFromMnemonic(testEncryptionKey, testMnemonic, 0); + wallet2 = await engine.createWalletFromMnemonic(testEncryptionKey, testMnemonic, 1); + + if (!isDefined(process.env.RUN_HARDHAT_TESTS)) { + return; + } + + provider = new PollingJsonRpcProvider(config.rpc, config.chainId, 500, 1); + const fallbackProvider = new FallbackProvider([{ provider, weight: 2 }]); + + chain = { + type: ChainType.EVM, + id: Number((await provider.getNetwork()).chainId), + }; + const pollingProvider = await createPollingJsonRpcProviderForListeners( + fallbackProvider, + chain.id, + ); + await engine.loadNetwork( + chain, + config.contracts.proxy, + config.contracts.relayAdapt, + config.contracts.poseidonMerkleAccumulatorV3, + config.contracts.poseidonMerkleVerifierV3, + config.contracts.tokenVaultV3, + fallbackProvider, + pollingProvider, + DEPLOYMENT_BLOCKS, + undefined, + !isV2Test(), // supportsV3 + config.contracts.relayAdapt7702, // relayAdapt7702ContractAddress + ); + await engine.scanContractHistory( + chain, + undefined, // walletIdFilter + ); + + ethersWallet = getEthersWallet(config.mnemonic, fallbackProvider); + snapshot = (await provider.send('evm_snapshot', [])) as number; + + nft = new Contract(NFT_ADDRESS, erc721Abi, ethersWallet) as unknown as TestERC721; + + testShieldBaseToken = async (value: bigint = 10000n): Promise => { + // Create shield + const shield = new ShieldNoteERC20( + wallet.masterPublicKey, + SHIELD_RANDOM, + value, + WETH_TOKEN_ADDRESS, + ); + const shieldPrivateKey = ByteUtils.hexToBytes(ByteUtils.randomHex(32)); + const shieldRequest = await shield.serialize( + shieldPrivateKey, + wallet.getViewingKeyPair().pubkey, + ); + + const shieldTx = await RelayAdaptVersionedSmartContracts.populateShieldBaseToken( + txidVersion, + chain, + shieldRequest, + false, // isRelayAdapt7702 + ); + + const shieldEventPromise = awaitRailgunSmartWalletShield(txidVersion, chain); + + // Send shield on chain + const tx = await sendTransactionWithLatestNonce(ethersWallet, shieldTx); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, txReceipt] = await Promise.all([ + shieldEventPromise, + tx.wait(), + promiseTimeout( + awaitScan(wallet, chain), + 20000, + 'Timed out shielding base token for relay adapt test setup', + ), + ]); + await wallet.refreshPOIsForTXIDVersion(chain, txidVersion, true); + + return txReceipt; + }; + }); + + it('[HH] Should wrap and shield base token', async function run() { + if (!isDefined(process.env.RUN_HARDHAT_TESTS)) { + this.skip(); + return; + } + + const { masterPublicKey } = wallet; + + // Create shield + const shield = new ShieldNoteERC20(masterPublicKey, SHIELD_RANDOM, 10000n, WETH_TOKEN_ADDRESS); + const shieldPrivateKey = ByteUtils.hexToBytes(ByteUtils.randomHex(32)); + const shieldRequest = await shield.serialize( + shieldPrivateKey, + wallet.getViewingKeyPair().pubkey, + ); + + // Manual 7702 Flow + const contract7702 = RelayAdaptVersionedSmartContracts.getRelayAdaptContract( + txidVersion, + chain, + true, + ) as RelayAdapt7702Contract; + + // Ephemeral Key + const ephemeralWallet = deriveEphemeralWallet(config.mnemonic, 0, BigInt(chain.id), 0); + + // HACK: Set code of ephemeral address to RelayAdapt7702 code to simulate 7702 delegation + const code = await provider.getCode(contract7702.address); + await provider.send('hardhat_setCode', [ephemeralWallet.address, code]); + + const orderedCalls = await contract7702.getOrderedCallsForShieldBaseToken( + shieldRequest, + ephemeralWallet.address, + ); + + const random31Bytes = ByteUtils.randomHex(31); + + const actionData = RelayAdapt7702Helper.getActionData( + random31Bytes, + true, // requireSuccess + orderedCalls, + BigInt(0), // minGasLimit + ); + + const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( + ephemeralWallet, + contract7702.address, + BigInt(chain.id), + 0, + ); + + // Sign Execution Auth + const signature = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + [], // transactions (empty for shield base token) + actionData, + BigInt(chain.id), + ); + + const shieldTx = await RelayAdaptVersionedSmartContracts.populateShieldBaseToken( + txidVersion, + chain, + shieldRequest, + true, // isRelayAdapt7702 + authorization, + signature, + random31Bytes, + ephemeralWallet.address, + ); + + const shieldEventPromise = awaitRailgunSmartWalletShield(txidVersion, chain); + + // Send shield on chain + const txResponse = await sendTransactionWithLatestNonce(ethersWallet, shieldTx); + + const receipt = await txResponse.wait(); + + if (!receipt) { + throw new Error('Missing transaction receipt for 7702 shield transaction.'); + } + + if (receipt.status === 0) { + console.error('Transaction failed'); + } + + await Promise.all([ + shieldEventPromise, + promiseTimeout(awaitScan(wallet, chain), 30000), + ]); + await wallet.refreshPOIsForTXIDVersion(chain, txidVersion, true); + + expect( + await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [ + WalletBalanceBucket.Spendable, + ]), + ).to.equal(9975n); + }).timeout(300_000); + + it('[HH] Should return gas estimate for unshield base token', async function run() { + if (!isDefined(process.env.RUN_HARDHAT_TESTS)) { + this.skip(); + return; + } + + await testShieldBaseToken(100000000n); + expect( + await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [ + WalletBalanceBucket.Spendable, + ]), + ).to.equal(99750000n); + + const transactionBatch = new TransactionBatch(chain); + + const broadcasterFee = TransactNote.createTransfer( + wallet2.addressKeys, + wallet.addressKeys, + 1000n, + wethTokenData, + false, // showSenderAddressToRecipient + OutputType.BroadcasterFee, + undefined, // memoText + ); + transactionBatch.addOutput(broadcasterFee); // Simulate Broadcaster fee output. + + const unshieldValue = 99000000n; + + // 7702 Setup + const contract7702 = RelayAdaptVersionedSmartContracts.getRelayAdaptContract( + txidVersion, + chain, + true, + ) as RelayAdapt7702Contract; + const ephemeralWallet = deriveEphemeralWallet(config.mnemonic, 0, BigInt(chain.id), 0); + + // HACK: Set code of ephemeral address to RelayAdapt7702 code to simulate 7702 delegation + const code = await provider.getCode(contract7702.address); + await provider.send('hardhat_setCode', [ephemeralWallet.address, code]); + + const unshieldNote = new UnshieldNoteERC20( + ephemeralWallet.address, + unshieldValue, + WETH_TOKEN_ADDRESS, + ); + transactionBatch.addUnshieldData(unshieldNote.unshieldData); + + const dummyTransactions = await transactionBatch.generateDummyTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + ); + + const random = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd'; + + const orderedCalls = await contract7702.getOrderedCallsForUnshieldBaseToken( + ethersWallet.address, + ephemeralWallet.address, + ); + + const actionData = RelayAdapt7702Helper.getActionData( + random, + false, // sendWithPublicWallet = false + orderedCalls, + BigInt(0), + ); + + const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( + ephemeralWallet, + contract7702.address, + BigInt(chain.id), + 0, + ); + + const signature = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + dummyTransactions, + actionData, + BigInt(chain.id), + ); + + const relayTransactionGasEstimate = + await RelayAdaptVersionedSmartContracts.populateUnshieldBaseToken( + txidVersion, + chain, + dummyTransactions, + ethersWallet.address, + random, + true, // useDummyProof + false, // sendWithPublicWallet + true, // isRelayAdapt7702 + authorization, + signature, + ephemeralWallet.address, + ); + + relayTransactionGasEstimate.from = DEAD_ADDRESS; + + const gasEstimate = await provider.estimateGas(relayTransactionGasEstimate); + expect(Number(gasEstimate)).to.be.greaterThan(0); + }).timeout(300_00); + + it('[HH] Should execute relay adapt transaction for unshield base token', async function run() { + if (!isDefined(process.env.RUN_HARDHAT_TESTS)) { + this.skip(); + return; + } + + await testShieldBaseToken(); + expect( + await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [ + WalletBalanceBucket.Spendable, + ]), + ).to.equal(9975n); + + // 1. Generate transaction batch to unshield necessary amount, and pay Broadcaster. + const transactionBatch = new TransactionBatch(chain); + const broadcasterFee = TransactNote.createTransfer( + wallet2.addressKeys, + wallet.addressKeys, + 100n, + wethTokenData, + false, // showSenderAddressToRecipient + OutputType.BroadcasterFee, + undefined, // memoText + ); + transactionBatch.addOutput(broadcasterFee); // Simulate Broadcaster fee output. + + const unshieldValue = 300n; + + // 7702 Setup + const contract7702 = RelayAdaptVersionedSmartContracts.getRelayAdaptContract( + txidVersion, + chain, + true, + ) as RelayAdapt7702Contract; + const ephemeralWallet = deriveEphemeralWallet(config.mnemonic, 0, BigInt(chain.id), 0); + + // HACK: Set code of ephemeral address to RelayAdapt7702 code to simulate 7702 delegation + const code = await provider.getCode(contract7702.address); + await provider.send('hardhat_setCode', [ephemeralWallet.address, code]); + + const unshieldNote = new UnshieldNoteERC20( + ephemeralWallet.address, + unshieldValue, + WETH_TOKEN_ADDRESS, + ); + transactionBatch.addUnshieldData(unshieldNote.unshieldData); + + // 2. Create dummy transactions from batch. + const dummyTransactions = await transactionBatch.generateDummyTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + ); + + // 3. Generate relay adapt params from dummy transactions. + const random = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd'; + + const relayAdaptParams = + await RelayAdaptVersionedSmartContracts.getRelayAdaptParamsUnshieldBaseToken( + txidVersion, + chain, + dummyTransactions, + ethersWallet.address, + random, + true, + true, // isRelayAdapt7702 + ephemeralWallet.address, + ); + expect(relayAdaptParams).to.equal(RelayAdapt7702Helper.getZeroAdaptParams()); + + // 4. Create real transactions with relay adapt params. + transactionBatch.setAdaptID({ + contract: ephemeralWallet.address, + parameters: relayAdaptParams, + }); + const { provedTransactions } = await transactionBatch.generateTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + () => { }, + false, // shouldGeneratePreTransactionPOIs + ); + for (const transaction of provedTransactions) { + expect((transaction as TransactionStructV2).boundParams.adaptContract).to.equal( + ephemeralWallet.address, + ); + expect((transaction as TransactionStructV2).boundParams.adaptParams).to.equal( + relayAdaptParams, + ); + } + + // const preEthBalance = await ethersWallet.getBalanceERC20(txidVersion, ); + + // 5: Generate final relay transaction for unshield base token. + const orderedCalls = await contract7702.getOrderedCallsForUnshieldBaseToken( + ethersWallet.address, + ephemeralWallet.address, + ); + + const actionData = RelayAdapt7702Helper.getActionData( + random, + true, // requireSuccess (sendWithPublicWallet=true) + orderedCalls, + BigInt(0), // minGasLimit + ); + + const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( + ephemeralWallet, + contract7702.address, + BigInt(chain.id), + 0, + ); + + // Sign Execution Auth + const signature = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + provedTransactions, + actionData, + BigInt(chain.id), + ); + + const relayTransaction = await RelayAdaptVersionedSmartContracts.populateUnshieldBaseToken( + txidVersion, + chain, + provedTransactions, + ethersWallet.address, + random, + true, // useDummyProof + true, // sendWithPublicWallet + true, // isRelayAdapt7702 + authorization, + signature, + ephemeralWallet.address, + ); + + // 6: Send relay transaction. + const transactEventPromise = awaitRailgunSmartWalletTransact(txidVersion, chain); + const unshieldEventPromise = awaitRailgunSmartWalletUnshield(txidVersion, chain); + + const txResponse = await sendTransactionWithLatestNonce(ethersWallet, relayTransaction); + + const awaiterScan = awaitMultipleScans(wallet, chain, 2); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_t, _u, txReceipt] = await Promise.all([ + transactEventPromise, + unshieldEventPromise, + txResponse.wait(), + awaiterScan, + ]); + if (txReceipt == null) { + throw new Error('No transaction receipt for relay transaction'); + } + await wallet.refreshPOIsForTXIDVersion(chain, txidVersion, true); + + expect( + await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [ + WalletBalanceBucket.Spendable, + ]), + ).to.equal(BigInt(9975 /* original */ - 100 /* broadcaster fee */ - 300 /* unshield amount */)); + + const callResultError = RelayAdapt7702Contract.getRelayAdaptCallError(txReceipt.logs); + expect(callResultError).to.equal(undefined); + + // TODO: Fix this test assertion. How much gas is used? + // const postEthBalance = await ethersWallet.getBalanceERC20(txidVersion, ); + // expect(preEthBalance - txReceipt.gasUsed + 300n).to.equal( + // postEthBalance, + // ); + }).timeout(300_000); + + it('[HH] Should execute relay adapt transaction for NFT transaction', async function run() { + if (!isDefined(process.env.RUN_HARDHAT_TESTS)) { + this.skip(); + return; + } + + // Shield WETH for Broadcaster fee. + await testShieldBaseToken(); + expect( + await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [ + WalletBalanceBucket.Spendable, + ]), + ).to.equal(9975n); + + // Mint NFTs with tokenIDs 0 and 1 into public balance. + await mintNFTsID01ForTest(nft, ethersWallet); + + // Approve NFT for shield. + const approval = await nft.approve.populateTransaction( + RailgunVersionedSmartContracts.getShieldApprovalContract(txidVersion, chain).address, + 1, + ); + const approvalTxResponse = await sendTransactionWithLatestNonce(ethersWallet, approval); + await approvalTxResponse.wait(); + + // Create shield + const shield = await shieldNFTForTest( + txidVersion, + wallet, + ethersWallet, + chain, + ByteUtils.randomHex(16), + NFT_ADDRESS, + '1', + ); + + const nftBalanceAfterShield = await nft.balanceOf( + RailgunVersionedSmartContracts.getAccumulator(txidVersion, chain).address, + ); + expect(nftBalanceAfterShield).to.equal(1n); + + const nftTokenData = shield.tokenData as NFTTokenData; + + // 1. Generate transaction batch to unshield necessary amount, and pay Broadcaster. + const transactionBatch = new TransactionBatch(chain); + const broadcasterFee = TransactNote.createTransfer( + wallet2.addressKeys, + wallet.addressKeys, + 300n, + wethTokenData, + false, // showSenderAddressToRecipient + OutputType.BroadcasterFee, + undefined, // memoText + ); + transactionBatch.addOutput(broadcasterFee); // Simulate Broadcaster fee output. + + const random = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd'; + + // 7702 Setup + const contract7702 = RelayAdaptVersionedSmartContracts.getRelayAdaptContract( + txidVersion, + chain, + true, + ) as RelayAdapt7702Contract; + const ephemeralWallet = deriveEphemeralWallet(config.mnemonic, 0, BigInt(chain.id), 0); + // HACK: Set code of ephemeral address to RelayAdapt7702 code to simulate 7702 delegation + const code = await provider.getCode(contract7702.address); + await provider.send('hardhat_setCode', [ephemeralWallet.address, code]); + + const unshieldNote = new UnshieldNoteNFT( + ephemeralWallet.address, + shield.tokenData as NFTTokenData, + ); + transactionBatch.addUnshieldData(unshieldNote.unshieldData); + + // 2. Create dummy transactions from batch. + const dummyTransactions = await transactionBatch.generateDummyTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + ); + + // 3. Create the cross contract calls. + // Do nothing for now. + // TODO: Add a test NFT interaction via cross contract call. + const crossContractCalls: ContractTransaction[] = []; + + const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( + ephemeralWallet, + contract7702.address, + BigInt(chain.id), + 0, + ); + + // 4. Create shield inputs. + const shieldRandom = '10203040506070809000102030405060'; + const relayShieldInputs = await RelayAdapt7702Helper.generateRelayShieldRequests( + shieldRandom, + [], + [{ nftTokenData, recipientAddress: wallet.getAddress() }], // shieldNFTRecipients + ); + + // 6. Get gas estimate from dummy txs. + const gasEstimateRandom = ByteUtils.randomHex(31); + + // Calculate params for signature + const orderedCallsGasEstimate = await contract7702.getOrderedCallsForCrossContractCalls( + crossContractCalls, + relayShieldInputs + ); + // HACK: Update to address for 7702 + orderedCallsGasEstimate.forEach((call) => { + if (call.to === contract7702.address) { + call.to = ephemeralWallet.address; + } + }); + + const minGasLimit = MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2; + const minGasLimitForContract = RelayAdapt7702Contract.getMinimumGasLimitForContract(minGasLimit); + + const actionDataGasEstimate = RelayAdapt7702Helper.getActionData( + gasEstimateRandom, + false, // requireSuccess (isGasEstimate=false, isBroadcasterTransaction=true -> false) + orderedCallsGasEstimate, + minGasLimitForContract, + ); + + const signatureGasEstimate = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + dummyTransactions, + actionDataGasEstimate, + BigInt(chain.id), + ); + + const populatedTransactionGasEstimate = + await RelayAdaptVersionedSmartContracts.populateCrossContractCalls( + txidVersion, + chain, + dummyTransactions, + crossContractCalls, + relayShieldInputs, + gasEstimateRandom, + false, // isGasEstimate + true, // isBroadcasterTransaction + undefined, + true, + authorization, + signatureGasEstimate, + ephemeralWallet.address + ); + populatedTransactionGasEstimate.from = DEAD_ADDRESS; + const gasEstimate = await RelayAdapt7702Contract.estimateGasWithErrorHandler( + provider, + populatedTransactionGasEstimate, + ); + expect(Number(gasEstimate)).to.be.greaterThan( + Number( + RelayAdapt7702Contract.getMinimumGasLimitForContract( + MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2, + ), + ), + ); + expect(Number(gasEstimate)).to.be.lessThan( + Number(MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2), + ); + // 7. Create real transactions with relay adapt params. + + const relayAdaptParams = + await RelayAdaptVersionedSmartContracts.getRelayAdaptParamsCrossContractCalls( + txidVersion, + chain, + dummyTransactions, + crossContractCalls, + relayShieldInputs, + random, + true, // isBroadcasterTransaction + undefined, // minGasLimit + true, // isRelayAdapt7702 + ephemeralWallet.address, + ); + transactionBatch.setAdaptID({ + contract: ephemeralWallet.address, + parameters: relayAdaptParams, + }); + const { provedTransactions } = await transactionBatch.generateTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + () => { }, + false, // shouldGeneratePreTransactionPOIs + ); + for (const transaction of provedTransactions) { + expect((transaction as TransactionStructV2).boundParams.adaptContract).to.equal( + ephemeralWallet.address, + ); + expect((transaction as TransactionStructV2).boundParams.adaptParams).to.equal( + relayAdaptParams, + ); + } + + // Sign Execution Auth + const orderedCallsFinal = await contract7702.getOrderedCallsForCrossContractCalls( + crossContractCalls, + relayShieldInputs + ); + // HACK: Update to address for 7702 + orderedCallsFinal.forEach((call) => { + if (call.to === contract7702.address) { + call.to = ephemeralWallet.address; + } + }); + + const actionDataFinal = RelayAdapt7702Helper.getActionData( + random, + false, // requireSuccess + orderedCallsFinal, + minGasLimitForContract, + ); + + const signatureFinal = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + provedTransactions, + actionDataFinal, + BigInt(chain.id), + ); + + // 8. Generate real relay transaction for cross contract call. + const relayTransaction = await RelayAdaptVersionedSmartContracts.populateCrossContractCalls( + txidVersion, + chain, + provedTransactions, + crossContractCalls, + relayShieldInputs, + random, + false, // isGasEstimate + true, // isBroadcasterTransaction + undefined, // minGasLimit + true, // isRelayAdapt7702 + authorization, + signatureFinal, + ephemeralWallet.address, + ); + const gasEstimateFinal = await provider.estimateGas(relayTransaction); + expect(Math.abs(Number(gasEstimate - gasEstimateFinal))).to.be.below( + 15000, + 'Gas difference from estimate (dummy) to final transaction should be less than 15000', + ); + + // 9: Send relay transaction. + const transactEventPromise = awaitRailgunSmartWalletTransact(txidVersion, chain); + const txResponse = await sendTransactionWithLatestNonce(ethersWallet, relayTransaction); + + // Perform scans: Unshield and Shield + const scansAwaiter = awaitMultipleScans(wallet, chain, 2); + + const [txReceipt] = await Promise.all([ + txResponse.wait(), + transactEventPromise, + ]); + if (txReceipt == null) { + throw new Error('No transaction receipt for relay transaction'); + } + + const callResultError = RelayAdapt7702Contract.getRelayAdaptCallError(txReceipt.logs); + expect(callResultError).to.equal(undefined); + + const nftBalanceAfterReshield = await nft.balanceOf( + RailgunVersionedSmartContracts.getAccumulator(txidVersion, chain).address, + ); + expect(nftBalanceAfterReshield).to.equal(1n); + }).timeout(300_000); + + it('[HH] Should shield all leftover WETH in relay adapt contract', async function run() { + if (!isDefined(process.env.RUN_HARDHAT_TESTS)) { + this.skip(); + return; + } + + await testShieldBaseToken(); + expect( + await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [ + WalletBalanceBucket.Spendable, + ]), + ).to.equal(9975n); + + // 1. Generate transaction batch to unshield necessary amount, and pay Broadcaster. + const transactionBatch = new TransactionBatch(chain); + + // 7702 Setup + const contract7702 = RelayAdaptVersionedSmartContracts.getRelayAdaptContract( + txidVersion, + chain, + true, + ) as RelayAdapt7702Contract; + const ephemeralWallet = deriveEphemeralWallet(config.mnemonic, 0, BigInt(chain.id), 0); + + // HACK: Set code of ephemeral address to RelayAdapt7702 code to simulate 7702 delegation + const code = await provider.getCode(contract7702.address); + await provider.send('hardhat_setCode', [ephemeralWallet.address, code]); + + const unshieldNote = new UnshieldNoteERC20( + ephemeralWallet.address, + 1000n, + WETH_TOKEN_ADDRESS, + ); + transactionBatch.addUnshieldData(unshieldNote.unshieldData); + + const { provedTransactions: serializedTxs } = await transactionBatch.generateTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + () => { }, + false, // shouldGeneratePreTransactionPOIs + ); + const transact = await RailgunVersionedSmartContracts.generateTransact( + txidVersion, + chain, + serializedTxs, + ); + + // Unshield to relay adapt. + const transactEventPromise = awaitRailgunSmartWalletTransact(txidVersion, chain); + const unshieldEventPromise = awaitRailgunSmartWalletUnshield(txidVersion, chain); + + const txTransact = await sendTransactionWithLatestNonce(ethersWallet, transact); + + await Promise.all([ + transactEventPromise, + unshieldEventPromise, + awaitMultipleScans(wallet, chain, 2), + txTransact.wait(), + ]); + + const wethTokenContract = new Contract( + WETH_TOKEN_ADDRESS, + erc20Abi, + ethersWallet, + ) as unknown as TestERC20; + + let relayAdaptAddressBalance: bigint = await wethTokenContract.balanceOf( + ephemeralWallet.address, + ); + expect(relayAdaptAddressBalance).to.equal(998n); + + // 9975 - 1000 + expect( + await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [ + WalletBalanceBucket.Spendable, + ]), + ).to.equal(8975n); + + // Value 0n doesn't matter - all WETH remaining in Relay Adapt will be shielded. + // Manual 7702 Shield of leftover + const shield = new ShieldNoteERC20( + wallet.masterPublicKey, + SHIELD_RANDOM, + 0n, // Shield all + WETH_TOKEN_ADDRESS, + ); + const shieldPrivateKey = ByteUtils.hexToBytes(ByteUtils.randomHex(32)); + const shieldRequest = await shield.serialize( + shieldPrivateKey, + wallet.getViewingKeyPair().pubkey, + ); + + const orderedCalls = await contract7702.getOrderedCallsForShieldBaseToken( + shieldRequest, + ephemeralWallet.address, + ); + + const random31Bytes = ByteUtils.randomHex(31); + + const actionData = RelayAdapt7702Helper.getActionData( + random31Bytes, + true, // requireSuccess + orderedCalls, + BigInt(0), // minGasLimit + ); + + const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( + ephemeralWallet, + contract7702.address, + BigInt(chain.id), + 0, + ); + + // Sign Execution Auth + const signature = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + [], // transactions (empty for shield base token) + actionData, + BigInt(chain.id), + ); + + const shieldTx = await RelayAdaptVersionedSmartContracts.populateShieldBaseToken( + txidVersion, + chain, + shieldRequest, + true, // isRelayAdapt7702 + authorization, + signature, + random31Bytes, + ephemeralWallet.address, + ); + + const shieldEventPromise = awaitRailgunSmartWalletShield(txidVersion, chain); + + // Send shield on chain + const tx = await sendTransactionWithLatestNonce(ethersWallet, shieldTx); + + await Promise.all([ + shieldEventPromise, + tx.wait(), + promiseTimeout( + awaitScan(wallet, chain), + 20000, + 'Timed out shielding base token for relay adapt test setup', + ), + ]); + await wallet.refreshPOIsForTXIDVersion(chain, txidVersion, true); + + relayAdaptAddressBalance = await wethTokenContract.balanceOf( + ephemeralWallet.address, + ); + expect(relayAdaptAddressBalance).to.equal(0n); + + // 9975 - 1000 + 998 - 2 (fee) + expect( + await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [ + WalletBalanceBucket.Spendable, + ]), + ).to.equal(9971n); + }).timeout(300_000); + + it('[HH] Should execute relay adapt transaction for cross contract call', async function run() { + if (!isDefined(process.env.RUN_HARDHAT_TESTS)) { + this.skip(); + return; + } + + await testShieldBaseToken(); + expect( + await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [ + WalletBalanceBucket.Spendable, + ]), + ).to.equal(9975n); + + // 1. Generate transaction batch to unshield necessary amount, and pay Broadcaster. + const transactionBatch = new TransactionBatch(chain); + const broadcasterFee = TransactNote.createTransfer( + wallet2.addressKeys, + wallet.addressKeys, + 300n, + wethTokenData, + false, // showSenderAddressToRecipient + OutputType.BroadcasterFee, + undefined, // memoText + ); + transactionBatch.addOutput(broadcasterFee); // Simulate Broadcaster fee output. + + // 7702 Setup + const contract7702 = RelayAdaptVersionedSmartContracts.getRelayAdaptContract( + txidVersion, + chain, + true, + ) as RelayAdapt7702Contract; + const ephemeralWallet = deriveEphemeralWallet(config.mnemonic, 0, BigInt(chain.id), 0); + + // HACK: Set code of ephemeral address to RelayAdapt7702 code to simulate 7702 delegation + const code = await provider.getCode(contract7702.address); + await provider.send('hardhat_setCode', [ephemeralWallet.address, code]); + + const unshieldNote = new UnshieldNoteERC20( + ephemeralWallet.address, + 1000n, + WETH_TOKEN_ADDRESS, + ); + transactionBatch.addUnshieldData(unshieldNote.unshieldData); + + // 2. Create dummy transactions from batch. + const dummyTransactions = await transactionBatch.generateDummyTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + ); + + // 3. Create the cross contract call. + // Cross contract call: send 990n WETH tokens to Dead address. + const wethTokenContract = new Contract( + WETH_TOKEN_ADDRESS, + erc20Abi, + ethersWallet, + ) as unknown as TestERC20; + const sendToAddress = DEAD_ADDRESS; + const sendAmount = 990n; + const crossContractCalls: ContractTransaction[] = [ + await wethTokenContract.transfer.populateTransaction(sendToAddress, sendAmount), + ]; + + // 4. Create shield inputs. + const shieldRandom = '10203040506070809000102030405060'; + const shieldERC20Addresses: RelayAdaptShieldERC20Recipient[] = [ + { tokenAddress: WETH_TOKEN_ADDRESS, recipientAddress: wallet.getAddress() }, + ]; + const relayShieldInputs = await RelayAdapt7702Helper.generateRelayShieldRequests( + shieldRandom, + shieldERC20Addresses, + [], // shieldNFTRecipients + ); + + // 5. Get gas estimate from dummy txs. + const randomGasEstimate = ByteUtils.randomHex(31); + + const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( + ephemeralWallet, + contract7702.address, + BigInt(chain.id), + 0, + ); + + const orderedCallsGasEstimate = await contract7702.getOrderedCallsForCrossContractCalls( + crossContractCalls, + relayShieldInputs + ); + // HACK: Update to address for 7702 + orderedCallsGasEstimate.forEach((call) => { + if (call.to === contract7702.address) { + call.to = ephemeralWallet.address; + } + }); + + const minGasLimit = MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2; + const minGasLimitForContract = RelayAdapt7702Contract.getMinimumGasLimitForContract(minGasLimit); + + const actionDataGasEstimate = RelayAdapt7702Helper.getActionData( + randomGasEstimate, + true, // requireSuccess (isGasEstimate=true -> true) + orderedCallsGasEstimate, + minGasLimitForContract, + ); + + const signatureGasEstimate = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + dummyTransactions, + actionDataGasEstimate, + BigInt(chain.id), + ); + + const populatedTransactionGasEstimate = + await RelayAdaptVersionedSmartContracts.populateCrossContractCalls( + txidVersion, + chain, + dummyTransactions, + crossContractCalls, + relayShieldInputs, + randomGasEstimate, + true, // isGasEstimate + true, // isBroadcasterTransaction + undefined, // minGasLimit + true, // isRelayAdapt7702 + authorization, + signatureGasEstimate, + ephemeralWallet.address, + ); + populatedTransactionGasEstimate.from = DEAD_ADDRESS; + const gasEstimate = await RelayAdapt7702Contract.estimateGasWithErrorHandler( + provider, + populatedTransactionGasEstimate, + ); + expect(Number(gasEstimate)).to.be.greaterThan( + Number( + RelayAdapt7702Contract.getMinimumGasLimitForContract( + MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2, + ), + ), + ); + expect(Number(gasEstimate)).to.be.lessThan( + Number(MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2), + ); + + // 6. Create real transactions with relay adapt params. + const random = ByteUtils.randomHex(31); + const relayAdaptParams = + await RelayAdaptVersionedSmartContracts.getRelayAdaptParamsCrossContractCalls( + txidVersion, + chain, + dummyTransactions, + crossContractCalls, + relayShieldInputs, + random, + true, // isBroadcasterTransaction + undefined, // minGasLimit + true, // isRelayAdapt7702 + ephemeralWallet.address, + ); + transactionBatch.setAdaptID({ + contract: ephemeralWallet.address, + parameters: relayAdaptParams, + }); + const { provedTransactions } = await transactionBatch.generateTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + () => { }, + false, // shouldGeneratePreTransactionPOIs + ); + for (const transaction of provedTransactions) { + expect((transaction as TransactionStructV2).boundParams.adaptContract).to.equal( + ephemeralWallet.address, + ); + expect((transaction as TransactionStructV2).boundParams.adaptParams).to.equal( + relayAdaptParams, + ); + } + + // Sign Execution Auth + const orderedCallsFinal = await contract7702.getOrderedCallsForCrossContractCalls( + crossContractCalls, + relayShieldInputs + ); + // HACK: Update to address for 7702 + orderedCallsFinal.forEach((call) => { + if (call.to === contract7702.address) { + call.to = ephemeralWallet.address; + } + }); + + const actionDataFinal = RelayAdapt7702Helper.getActionData( + random, + false, // requireSuccess + orderedCallsFinal, + minGasLimitForContract, + ); + + const signatureFinal = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + provedTransactions, + actionDataFinal, + BigInt(chain.id), + ); + + // 7. Generate real relay transaction for cross contract call. + const relayTransaction = await RelayAdaptVersionedSmartContracts.populateCrossContractCalls( + txidVersion, + chain, + provedTransactions, + crossContractCalls, + relayShieldInputs, + random, + false, // isGasEstimate + true, // isBroadcasterTransaction + undefined, // minGasLimit + true, // isRelayAdapt7702 + authorization, + signatureFinal, + ephemeralWallet.address, + ); + const gasEstimateFinal = await provider.estimateGas(relayTransaction); + + expect(Math.abs(Number(gasEstimate - gasEstimateFinal))).to.be.below( + 10000, + 'Gas difference from estimate (dummy) to final transaction should be less than 10000', + ); + + // Add 20% to gasEstimate for gasLimit. + relayTransaction.gasLimit = (gasEstimate * 120n) / 100n; + + // 8. Send transaction. + const transactEventPromise = awaitRailgunSmartWalletTransact(txidVersion, chain); + const txResponse = await sendTransactionWithLatestNonce(ethersWallet, relayTransaction); + + // Perform scans: Unshield and Shield + const scansAwaiter = awaitMultipleScans(wallet, chain, 2); + + const [txReceipt] = await Promise.all([ + txResponse.wait(), + transactEventPromise, + ]); + if (txReceipt == null) { + throw new Error('No transaction receipt for relay transaction'); + } + + await expect(scansAwaiter).to.be.fulfilled; + await wallet.refreshPOIsForTXIDVersion(chain, txidVersion, true); + + // Dead address should have 990n WETH. + const sendAddressBalance: bigint = await wethTokenContract.balanceOf(sendToAddress); + expect(sendAddressBalance).to.equal(sendAmount); + + const relayAdaptAddressBalance: bigint = await wethTokenContract.balanceOf( + RelayAdaptVersionedSmartContracts.getRelayAdaptContract(txidVersion, chain, true).address, + ); + expect(relayAdaptAddressBalance).to.equal(0n); + + const callResultError = RelayAdapt7702Contract.getRelayAdaptCallError(txReceipt.logs); + expect(callResultError).to.equal(undefined); + + const expectedPrivateWethBalance = BigInt( + 9975 /* original shield */ - + 300 /* broadcaster fee */ - + 1000 /* unshield */ + + 8 /* re-shield (1000 unshield amount - 2 unshield fee - 990 send amount - 0 re-shield fee) */, + ); + const expectedTotalPrivateWethBalance = expectedPrivateWethBalance + 300n; // Add broadcaster fee. + + const proxyWethBalance = await wethTokenContract.balanceOf( + RailgunVersionedSmartContracts.getAccumulator(txidVersion, chain).address, + ); + expect(proxyWethBalance).to.equal(expectedTotalPrivateWethBalance); + + const privateWalletBalance = await wallet.getBalanceERC20( + txidVersion, + chain, + WETH_TOKEN_ADDRESS, + [WalletBalanceBucket.Spendable], + ); + expect(privateWalletBalance).to.equal(expectedPrivateWethBalance); + }).timeout(300_000); + + it('[HH] Should revert send, but keep fees for failing cross contract call', async function run() { + if (!isDefined(process.env.RUN_HARDHAT_TESTS)) { + this.skip(); + return; + } + + await testShieldBaseToken(100000n); + expect( + await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [ + WalletBalanceBucket.Spendable, + ]), + ).to.equal(99750n); + + // 1. Generate transaction batch to unshield necessary amount, and pay Broadcaster. + const transactionBatch = new TransactionBatch(chain); + const broadcasterFee = TransactNote.createTransfer( + wallet2.addressKeys, + wallet.addressKeys, + 300n, + wethTokenData, + false, // showSenderAddressToRecipient + OutputType.BroadcasterFee, + undefined, // memoText + ); + transactionBatch.addOutput(broadcasterFee); // Simulate Broadcaster fee output. + + // 7702 Setup + const contract7702 = RelayAdaptVersionedSmartContracts.getRelayAdaptContract( + txidVersion, + chain, + true, + ) as RelayAdapt7702Contract; + const ephemeralWallet = deriveEphemeralWallet(config.mnemonic, 0, BigInt(chain.id), 0); + + // HACK: Set code of ephemeral address to RelayAdapt7702 code to simulate 7702 delegation + const code = await provider.getCode(contract7702.address); + await provider.send('hardhat_setCode', [ephemeralWallet.address, code]); + + const unshieldNote = new UnshieldNoteERC20( + ephemeralWallet.address, + 10000n, + WETH_TOKEN_ADDRESS, + ); + transactionBatch.addUnshieldData(unshieldNote.unshieldData); + + // 2. Create dummy transactions from batch. + const dummyTransactions = await transactionBatch.generateDummyTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + ); + + // 3. Create the cross contract call. + // Cross contract call: send 1 WETH token to Dead address. + const wethTokenContract = new Contract( + WETH_TOKEN_ADDRESS, + erc20Abi, + ethersWallet, + ) as unknown as TestERC20; + const sendToAddress = DEAD_ADDRESS; + const sendAmount = 20000n; // More than is available (after 0.25% unshield fee). + const crossContractCalls: ContractTransaction[] = [ + await wethTokenContract.transfer.populateTransaction(sendToAddress, sendAmount), + ]; + + // 4. Create shield inputs. + const shieldRandom = '10203040506070809000102030405060'; + const shieldERC20Addresses: RelayAdaptShieldERC20Recipient[] = [ + { tokenAddress: WETH_TOKEN_ADDRESS, recipientAddress: wallet.getAddress() }, + ]; + const relayShieldInputs = await RelayAdapt7702Helper.generateRelayShieldRequests( + shieldRandom, + shieldERC20Addresses, + [], // shieldNFTRecipients + ); + + // 5. Get gas estimate from dummy txs. (Expect revert). + const gasEstimateRandom = ByteUtils.randomHex(31); + + const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( + ephemeralWallet, + contract7702.address, + BigInt(chain.id), + 0, + ); + + const orderedCallsGasEstimate = await contract7702.getOrderedCallsForCrossContractCalls( + crossContractCalls, + relayShieldInputs + ); + // HACK: Update to address for 7702 + orderedCallsGasEstimate.forEach((call) => { + if (call.to === contract7702.address) { + call.to = ephemeralWallet.address; + } + }); + + const minGasLimit = MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2; + const minGasLimitForContract = RelayAdapt7702Contract.getMinimumGasLimitForContract(minGasLimit); + + const actionDataGasEstimate = RelayAdapt7702Helper.getActionData( + gasEstimateRandom, + true, // requireSuccess (isGasEstimate=true -> true) + orderedCallsGasEstimate, + minGasLimitForContract, + ); + + const signatureGasEstimate = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + dummyTransactions, + actionDataGasEstimate, + BigInt(chain.id), + ); + + const populatedTransactionGasEstimate = + await RelayAdaptVersionedSmartContracts.populateCrossContractCalls( + txidVersion, + chain, + dummyTransactions, + crossContractCalls, + relayShieldInputs, + gasEstimateRandom, + true, // isGasEstimate + true, // isBroadcasterTransaction + undefined, // minGasLimit + true, // isRelayAdapt7702 + authorization, + signatureGasEstimate, + ephemeralWallet.address, + ); + populatedTransactionGasEstimate.from = DEAD_ADDRESS; + await expect( + RelayAdapt7702Contract.estimateGasWithErrorHandler(provider, populatedTransactionGasEstimate), + ).to.be.rejectedWith('RelayAdapt multicall failed at index 0.'); + + // 6. Create real transactions with relay adapt params. + const random = ByteUtils.randomHex(31); + const relayAdaptParams = + await RelayAdaptVersionedSmartContracts.getRelayAdaptParamsCrossContractCalls( + txidVersion, + chain, + dummyTransactions, + crossContractCalls, + relayShieldInputs, + random, + true, // isBroadcasterTransaction + undefined, // minGasLimit + true, // isRelayAdapt7702 + ephemeralWallet.address, + ); + transactionBatch.setAdaptID({ + contract: ephemeralWallet.address, + parameters: relayAdaptParams, + }); + const { provedTransactions } = await transactionBatch.generateTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + () => { }, + false, // shouldGeneratePreTransactionPOIs + ); + for (const transaction of provedTransactions) { + expect((transaction as TransactionStructV2).boundParams.adaptContract).to.equal( + ephemeralWallet.address, + ); + expect((transaction as TransactionStructV2).boundParams.adaptParams).to.equal( + relayAdaptParams, + ); + } + + // Sign Execution Auth + const orderedCallsFinal = await contract7702.getOrderedCallsForCrossContractCalls( + crossContractCalls, + relayShieldInputs + ); + // HACK: Update to address for 7702 + orderedCallsFinal.forEach((call) => { + if (call.to === contract7702.address) { + call.to = ephemeralWallet.address; + } + }); + + const actionDataFinal = RelayAdapt7702Helper.getActionData( + random, + false, // requireSuccess + orderedCallsFinal, + minGasLimitForContract, + ); + + const signatureFinal = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + provedTransactions, + actionDataFinal, + BigInt(chain.id), + ); + + // 7. Generate real relay transaction for cross contract call. + const relayTransaction = await RelayAdaptVersionedSmartContracts.populateCrossContractCalls( + txidVersion, + chain, + provedTransactions, + crossContractCalls, + relayShieldInputs, + random, + false, // isGasEstimate + true, // isBroadcasterTransaction + undefined, // minGasLimit + true, // isRelayAdapt7702 + authorization, + signatureFinal, + ephemeralWallet.address, + ); + + // Set high gas limit. + relayTransaction.gasLimit = BigInt('15000000'); + + // 8. Send transaction. + const transactEventPromise = awaitRailgunSmartWalletTransact(txidVersion, chain); + const unshieldEventPromise = awaitRailgunSmartWalletUnshield(txidVersion, chain); + + const txResponse = await sendTransactionWithLatestNonce(ethersWallet, relayTransaction); + + // Perform scans: Unshield and Shield + const scansAwaiter = awaitMultipleScans(wallet, chain, 2); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_t, _u, txReceipt] = await Promise.all([ + transactEventPromise, + unshieldEventPromise, + txResponse.wait(), + ]); + if (txReceipt == null) { + throw new Error('No transaction receipt for relay transaction'); + } + + await expect(scansAwaiter).to.be.fulfilled; + await wallet.refreshPOIsForTXIDVersion(chain, txidVersion, true); + + // Dead address should have 0 WETH. + const sendAddressBalance: bigint = await wethTokenContract.balanceOf(sendToAddress); + expect(sendAddressBalance).to.equal(0n); + + const relayAdaptAddressBalance: bigint = await wethTokenContract.balanceOf( + RelayAdaptVersionedSmartContracts.getRelayAdaptContract(txidVersion, chain, true).address, + ); + expect(relayAdaptAddressBalance).to.equal(0n); + + const callResultError = RelayAdapt7702Contract.getRelayAdaptCallError(txReceipt.logs); + expect(callResultError).to.equal( + 'Unknown Relay Adapt error: No utf8 string parsed from revert reason.', + ); + + const expectedPrivateWethBalance = BigInt( + 99750 /* original */ - + 300 /* broadcaster fee */ - + 10000 /* unshield amount */ - + 0 /* failed cross contract send: no change */ + + 9975 /* re-shield amount */ - + 24 /* shield fee */, + ); + const expectedTotalPrivateWethBalance = expectedPrivateWethBalance + 300n; // Add broadcaster fee. + + const proxyWethBalance = await wethTokenContract.balanceOf( + RailgunVersionedSmartContracts.getAccumulator(txidVersion, chain).address, + ); + const privateWalletBalance = await wallet.getBalanceERC20( + txidVersion, + chain, + WETH_TOKEN_ADDRESS, + [WalletBalanceBucket.Spendable], + ); + + expect(proxyWethBalance).to.equal(expectedTotalPrivateWethBalance); + expect(privateWalletBalance).to.equal(expectedPrivateWethBalance); + }).timeout(300_000); + + it('[HH] Should revert send for failing re-shield', async function run() { + if (!isDefined(process.env.RUN_HARDHAT_TESTS)) { + this.skip(); + return; + } + + await testShieldBaseToken(100000n); + expect( + await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [ + WalletBalanceBucket.Spendable, + ]), + ).to.equal(99750n); + + // 1. Generate transaction batch to unshield necessary amount, and pay Broadcaster. + const transactionBatch = new TransactionBatch(chain); + const broadcasterFee = TransactNote.createTransfer( + wallet2.addressKeys, + wallet.addressKeys, + 300n, + wethTokenData, + false, // showSenderAddressToRecipient + OutputType.BroadcasterFee, + undefined, // memoText + ); + transactionBatch.addOutput(broadcasterFee); // Simulate Broadcaster fee output. + + // 7702 Setup + const contract7702 = RelayAdaptVersionedSmartContracts.getRelayAdaptContract( + txidVersion, + chain, + true, + ) as RelayAdapt7702Contract; + const ephemeralWallet = deriveEphemeralWallet(config.mnemonic, 0, BigInt(chain.id), 0); + + // HACK: Set code of ephemeral address to RelayAdapt7702 code to simulate 7702 delegation + const code = await provider.getCode(contract7702.address); + await provider.send('hardhat_setCode', [ephemeralWallet.address, code]); + + const unshieldNote = new UnshieldNoteERC20( + ephemeralWallet.address, + 10000n, + WETH_TOKEN_ADDRESS, + ); + transactionBatch.addUnshieldData(unshieldNote.unshieldData); + + // 2. Create dummy transactions from batch. + const dummyTransactions = await transactionBatch.generateDummyTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + ); + + // 3. Create the cross contract call. + // Cross contract call: send 1 WETH token to Dead address. + const wethTokenContract = new Contract( + WETH_TOKEN_ADDRESS, + erc20Abi, + ethersWallet, + ) as unknown as TestERC20; + const sendToAddress = DEAD_ADDRESS; + const sendAmount = 20000n; // More than is available (after 0.25% unshield fee). + const crossContractCalls: ContractTransaction[] = [ + await wethTokenContract.transfer.populateTransaction(sendToAddress, sendAmount), + ]; + + // 4. Create shield inputs. + const shieldRandom = '10203040506070809000102030405060'; + const shieldERC20Addresses: RelayAdaptShieldERC20Recipient[] = [ + { tokenAddress: WETH_TOKEN_ADDRESS, recipientAddress: wallet.getAddress() }, + ]; + const relayShieldInputs = await RelayAdapt7702Helper.generateRelayShieldRequests( + shieldRandom, + shieldERC20Addresses, + [], // shieldNFTRecipients + ); + + // 5. Get gas estimate from dummy txs. (Expect revert). + const randomGasEstimate = ByteUtils.randomHex(31); + + const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( + ephemeralWallet, + contract7702.address, + BigInt(chain.id), + 0, + ); + + const orderedCallsGasEstimate = await contract7702.getOrderedCallsForCrossContractCalls( + crossContractCalls, + relayShieldInputs + ); + // HACK: Update to address for 7702 + orderedCallsGasEstimate.forEach((call) => { + if (call.to === contract7702.address) { + call.to = ephemeralWallet.address; + } + }); + + const minGasLimit = MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2; + const minGasLimitForContract = RelayAdapt7702Contract.getMinimumGasLimitForContract(minGasLimit); + + const actionDataGasEstimate = RelayAdapt7702Helper.getActionData( + randomGasEstimate, + true, // requireSuccess (isGasEstimate=true -> true) + orderedCallsGasEstimate, + minGasLimitForContract, + ); + + const signatureGasEstimate = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + dummyTransactions, + actionDataGasEstimate, + BigInt(chain.id), + ); + + const populatedTransactionGasEstimate = + await RelayAdaptVersionedSmartContracts.populateCrossContractCalls( + txidVersion, + chain, + dummyTransactions, + crossContractCalls, + relayShieldInputs, + randomGasEstimate, + true, // isGasEstimate + true, // isBroadcasterTransaction + undefined, // minGasLimit + true, // isRelayAdapt7702 + authorization, + signatureGasEstimate, + ephemeralWallet.address, + ); + populatedTransactionGasEstimate.from = DEAD_ADDRESS; + await expect( + RelayAdapt7702Contract.estimateGasWithErrorHandler(provider, populatedTransactionGasEstimate), + ).to.be.rejectedWith('RelayAdapt multicall failed at index 0.'); + + // 6. Create real transactions with relay adapt params. + const random = ByteUtils.randomHex(31); + const relayAdaptParams = + await RelayAdaptVersionedSmartContracts.getRelayAdaptParamsCrossContractCalls( + txidVersion, + chain, + dummyTransactions, + crossContractCalls, + relayShieldInputs, + random, + true, // isBroadcasterTransaction + undefined, // minGasLimit + true, // isRelayAdapt7702 + ephemeralWallet.address, + ); + transactionBatch.setAdaptID({ + contract: ephemeralWallet.address, + parameters: relayAdaptParams, + }); + const { provedTransactions } = await transactionBatch.generateTransactions( + engine.prover, + wallet, + txidVersion, + testEncryptionKey, + () => { }, + false, // shouldGeneratePreTransactionPOIs + ); + for (const transaction of provedTransactions) { + expect((transaction as TransactionStructV2).boundParams.adaptContract).to.equal( + ephemeralWallet.address, + ); + expect((transaction as TransactionStructV2).boundParams.adaptParams).to.equal( + relayAdaptParams, + ); + } + + // Sign Execution Auth + const orderedCallsFinal = await contract7702.getOrderedCallsForCrossContractCalls( + crossContractCalls, + relayShieldInputs + ); + // HACK: Update to address for 7702 + orderedCallsFinal.forEach((call) => { + if (call.to === contract7702.address) { + call.to = ephemeralWallet.address; + } + }); + + const actionDataFinal = RelayAdapt7702Helper.getActionData( + random, + false, // requireSuccess + orderedCallsFinal, + minGasLimitForContract, + ); + + const signatureFinal = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + provedTransactions, + actionDataFinal, + BigInt(chain.id), + ); + + // 7. Generate real relay transaction for cross contract call. + const relayTransaction = await RelayAdaptVersionedSmartContracts.populateCrossContractCalls( + txidVersion, + chain, + provedTransactions, + crossContractCalls, + relayShieldInputs, + random, + false, // isGasEstimate + true, // isBroadcasterTransaction + undefined, // minGasLimit + true, // isRelayAdapt7702 + authorization, + signatureFinal, + ephemeralWallet.address, + ); + + const gasEstimateFinal = await provider.estimateGas(relayTransaction); + + // Gas estimate is currently an underestimate (which is a bug). + // Set gas limit to this value, which should revert inside the smart contract. + relayTransaction.gasLimit = (gasEstimateFinal * 101n) / 100n; + + // 8. Send transaction. + const transactEventPromise = awaitRailgunSmartWalletTransact(txidVersion, chain); + const txResponse = await sendTransactionWithLatestNonce(ethersWallet, relayTransaction); + + // Perform scans: Unshield and Shield + const scansAwaiter = awaitMultipleScans(wallet, chain, 2); + + const [txReceipt] = await Promise.all([ + txResponse.wait(), + scansAwaiter, + transactEventPromise, + ]); + + if (txReceipt == null) { + throw new Error('No transaction receipt for relay transaction'); + } + + await expect(scansAwaiter).to.be.fulfilled; + await wallet.refreshPOIsForTXIDVersion(chain, txidVersion, true); + + // Dead address should have 0 WETH. + const sendAddressBalance: bigint = await wethTokenContract.balanceOf(sendToAddress); + expect(sendAddressBalance).to.equal(0n); + + const relayAdaptAddressBalance: bigint = await wethTokenContract.balanceOf( + RelayAdaptVersionedSmartContracts.getRelayAdaptContract(txidVersion, chain, true).address, + ); + + expect(relayAdaptAddressBalance).to.equal(0n); + + const callResultError = RelayAdapt7702Contract.getRelayAdaptCallError(txReceipt.logs); + expect(callResultError).to.equal( + 'Unknown Relay Adapt error: No utf8 string parsed from revert reason.', + ); + + // TODO: These are the incorrect assertions, if the tx is fully reverted. This requires a callbacks upgrade to contract. + // For now, it is partially reverted. Unshield/shield fees are still charged. + // This caps the loss of funds at 0.5% + Broadcaster fee. + + const expectedProxyBalance = BigInt( + 99750 /* original */ - 25 /* unshield fee */ - 24 /* re-shield fee */, + ); + const expectedWalletBalance = BigInt(expectedProxyBalance - 300n /* broadcaster fee */); + + const treasuryBalance: bigint = await wethTokenContract.balanceOf( + config.contracts.treasuryProxy, + ); + expect(treasuryBalance).to.equal(299n); + + const proxyWethBalance = await wethTokenContract.balanceOf( + RailgunVersionedSmartContracts.getAccumulator(txidVersion, chain).address, + ); + const privateWalletBalance = await wallet.getBalanceERC20( + txidVersion, + chain, + WETH_TOKEN_ADDRESS, + [WalletBalanceBucket.Spendable], + ); + + expect(proxyWethBalance).to.equal(expectedProxyBalance); + expect(privateWalletBalance).to.equal(expectedWalletBalance); + + // + // These are the correct assertions.... + // + + // const expectedPrivateWethBalance = BigInt(99750 /* original */); + + // const treasuryBalance: bigint = await wethTokenContract.balanceOf(config.contracts.treasuryProxy); + // expect(treasuryBalance).to.equal(250n); + + // const proxyWethBalance = (await wethTokenContract.balanceOf(RailgunVersionedSmartContracts.getAccumulator(txidVersion, chain).address)); + // const privateWalletBalance = await wallet.getBalanceERC20(txidVersion, chain, WETH_TOKEN_ADDRESS, [WalletBalanceBucket.Spendable]); + + // expect(proxyWethBalance).to.equal(expectedPrivateWethBalance); + // expect(privateWalletBalance).to.equal(expectedPrivateWethBalance); + }).timeout(300_000); + + it('Should generate relay shield notes and inputs', async () => { + const shieldERC20Recipients: RelayAdaptShieldERC20Recipient[] = [ + { + tokenAddress: config.contracts.weth9.toLowerCase(), + recipientAddress: wallet.getAddress(), + }, + { + tokenAddress: config.contracts.rail.toLowerCase(), + recipientAddress: wallet.getAddress(), + }, + ]; + + const random = '10203040506070809000102030405060'; + const relayShieldInputs = await RelayAdapt7702Helper.generateRelayShieldRequests( + random, + shieldERC20Recipients, + [], // shieldNFTRecipients + ); + + expect(relayShieldInputs.length).to.equal(2); + expect( + relayShieldInputs.map((shieldInput) => shieldInput.preimage.token.tokenAddress), + ).to.deep.equal(shieldERC20Recipients.map((recipient) => recipient.tokenAddress.toLowerCase())); + for (const relayShieldInput of relayShieldInputs) { + expect(relayShieldInput.preimage.npk).to.equal( + ByteUtils.nToHex( + 3348140451435708797167073859596593490034226162440317170509481065740328487080n, + ByteLength.UINT_256, + true, + ), + ); + expect(relayShieldInput.preimage.token.tokenType).to.equal(0); + } + }); + + it('Should use zero adapt params for 7702 proofs', () => { + expect(RelayAdapt7702Helper.getZeroAdaptParams()).to.equal( + '0x0000000000000000000000000000000000000000000000000000000000000000', + ); + }); + + it('Should decode and parse relay adapt error logs (from failed Sushi V2 LP removal)', () => { + const relayAdaptError = RelayAdapt7702Contract.getRelayAdaptCallError( + FormattedRelayAdaptErrorLogs, + ); + expect(relayAdaptError).to.equal('ds-math-sub-underflow'); + }); + + it('Should extract call failed index and error message from ethers error', () => { + const errorText = `execution reverted (unknown custom error) (action="estimateGas", data="0x5c0dee5d00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006408c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001564732d6d6174682d7375622d756e646572666c6f77000000000000000000000000000000000000000000000000000000000000000000000000000000", reason=null, transaction={ "data": "0x28223a77000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000007a00000000000000000000000000000000000000000000000000…00000000004640cd6086ade3e984b011b4e8c7cab9369b90499ab88222e673ec1ae4d2c3bf78ae96e95f9171653e5b1410273269edd64a0ab792a5d355093caa9cb92406125c7803a48028503783f2ab5e84f0ea270ce770860e436b77c942ed904a5d577d021cf0fd936183e0298175679d63d73902e116484e10c7b558d4dc84e113380500000000000000000000000000000000000000000000000000000000", "from": "0x000000000000000000000000000000000000dEaD", "to": "0x0355B7B8cb128fA5692729Ab3AAa199C1753f726" }, invocation=null, revert=null, code=CALL_EXCEPTION, version=6.4.0)`; + const { callFailedIndexString, errorMessage } = + RelayAdapt7702Contract.extractGasEstimateCallFailedIndexAndErrorText(errorText); + expect(callFailedIndexString).to.equal('5'); + expect(errorMessage).to.equal( + `'execution reverted (unknown custom error)': ds-math-sub-underflow`, + ); + }); + + it('Should parse relay adapt log revert data - relay adapt abi value', () => { + const parsed = RelayAdapt7702Contract.parseRelayAdaptReturnValue( + `0x5c0dee5d00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006408c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001564732d6d6174682d7375622d756e646572666c6f77000000000000000000000000000000000000000000000000000000000000000000000000000000`, + ); + expect(parsed?.callIndex).to.equal(5); + expect(parsed?.error).to.equal('ds-math-sub-underflow'); + }); + + it('Should parse relay adapt log revert data - string value', () => { + const parsed = RelayAdapt7702Contract.parseRelayAdaptReturnValue( + `0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000205261696c67756e4c6f6769633a204e6f746520616c7265616479207370656e74`, + ); + expect(parsed?.callIndex).to.equal(undefined); + expect(parsed?.error).to.equal('RailgunLogic: Note already spent'); + }); + + it('Should parse relay adapt log revert data - string value from railgun cookbook transaction', () => { + const parsed = RelayAdapt7702Contract.parseRelayAdaptReturnValue( + `0x5c0dee5d00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002d52656c617941646170743a205265667573696e6720746f2063616c6c205261696c67756e20636f6e747261637400000000000000000000000000000000000000`, + ); + expect(parsed?.callIndex).to.equal(2); + expect(parsed?.error).to.equal('RelayAdapt: Refusing to call Railgun contract'); + }); + + it('Should extract call failed index and error message from non-parseable ethers error', () => { + const errorText = `not a parseable error`; + const { callFailedIndexString, errorMessage } = + RelayAdapt7702Contract.extractGasEstimateCallFailedIndexAndErrorText(errorText); + expect(callFailedIndexString).to.equal('UNKNOWN'); + expect(errorMessage).to.equal('not a parseable error'); + }); + + afterEach(async () => { + if (!isDefined(process.env.RUN_HARDHAT_TESTS)) { + return; + } + await engine.unload(); + await provider.send('evm_revert', [snapshot]); + }); +}); diff --git a/src/contracts/relay-adapt/index.ts b/src/contracts/relay-adapt/index.ts index 206c404f..7ef2149a 100644 --- a/src/contracts/relay-adapt/index.ts +++ b/src/contracts/relay-adapt/index.ts @@ -1,3 +1,6 @@ export * from './relay-adapt-versioned-smart-contracts'; export * from './relay-adapt-helper'; +export * from './relay-adapt-7702-helper'; export * from './constants'; +export * from './relay-adapt-types'; + diff --git a/src/contracts/relay-adapt/relay-adapt-7702-helper.ts b/src/contracts/relay-adapt/relay-adapt-7702-helper.ts new file mode 100644 index 00000000..ee4eb510 --- /dev/null +++ b/src/contracts/relay-adapt/relay-adapt-7702-helper.ts @@ -0,0 +1,186 @@ +import { ContractTransaction, Wallet, Interface, HDNodeWallet, Authorization } from 'ethers'; +import { ByteUtils } from '../../utils/bytes'; +import { ShieldNoteERC20 } from '../../note/erc20/shield-note-erc20'; +import { AddressData, decodeAddress } from '../../key-derivation'; +import { + NFTTokenData, + RelayAdaptShieldERC20Recipient, + RelayAdaptShieldNFTRecipient, + TokenType, +} from '../../models/formatted-types'; +import { ShieldNoteNFT } from '../../note/nft/shield-note-nft'; +import { ERC721_NOTE_VALUE } from '../../note/note-util'; +import { ShieldRequestStruct } from '../../abi/typechain/RelayAdapt'; +import { RelayAdapt7702 } from '../../abi/typechain/RelayAdapt7702'; +import { TransactionStructV2, TransactionStructV3 } from '../../models/transaction-types'; +import { signEIP7702Authorization as signEIP7702AuthorizationCore } from '../../transaction/eip7702'; +import { + getExecutePayloadHash, + signExecutionAuthorization as signExecutionAuthorizationCore, + ZERO_7702_ADAPT_PARAMS, +} from '../../transaction/relay-adapt-7702-signature'; + +class RelayAdapt7702Helper { + /** + * Signs an EIP-7702 Authorization using ethers native methods. + * @param signer - The ephemeral key signer + * @param contractAddress - The address to delegate to (RelayAdapt7702) + * @param chainId - Chain ID + * @param nonce - Nonce (typically 0 for ephemeral keys) + * @returns Authorization tuple + */ + static async signEIP7702Authorization( + signer: Wallet | HDNodeWallet, + contractAddress: string, + chainId: bigint, + nonce: number, + ): Promise { + return signEIP7702AuthorizationCore(signer, contractAddress, chainId, nonce); + } + + static async signExecutionAuthorization( + signer: Wallet | HDNodeWallet, + transactions: (TransactionStructV2 | TransactionStructV3)[], + actionData: RelayAdapt7702.ActionDataStruct, + chainId: bigint, + ): Promise { + return signExecutionAuthorizationCore(signer, transactions, actionData, Number(chainId)); + } + + static getExecutePayloadHash( + transactions: (TransactionStructV2 | TransactionStructV3)[], + actionData: RelayAdapt7702.ActionDataStruct, + ): string { + return getExecutePayloadHash(transactions, actionData); + } + + static getZeroAdaptParams(): string { + return ZERO_7702_ADAPT_PARAMS; + } + + static async generateRelayShieldRequests( + random: string, + shieldERC20Recipients: RelayAdaptShieldERC20Recipient[], + shieldNFTRecipients: RelayAdaptShieldNFTRecipient[], + ): Promise { + return Promise.all([ + ...(await RelayAdapt7702Helper.createRelayShieldRequestsERC20s(random, shieldERC20Recipients)), + ...(await RelayAdapt7702Helper.createRelayShieldRequestsNFTs(random, shieldNFTRecipients)), + ]); + } + + private static async createRelayShieldRequestsERC20s( + random: string, + shieldERC20Recipients: RelayAdaptShieldERC20Recipient[], + ): Promise { + return Promise.all( + shieldERC20Recipients.map(({ tokenAddress, recipientAddress }) => { + const addressData: AddressData = decodeAddress(recipientAddress); + const shieldERC20 = new ShieldNoteERC20( + addressData.masterPublicKey, + random, + 0n, // 0n will automatically shield entire balance. + tokenAddress, + ); + + // Random private key for Relay Adapt shield. + const shieldPrivateKey = ByteUtils.hexToBytes(ByteUtils.randomHex(32)); + + return shieldERC20.serialize(shieldPrivateKey, addressData.viewingPublicKey); + }), + ); + } + + private static async createRelayShieldRequestsNFTs( + random: string, + shieldNFTRecipients: RelayAdaptShieldNFTRecipient[], + ): Promise { + return Promise.all( + shieldNFTRecipients.map(({ nftTokenData, recipientAddress }) => { + const value = RelayAdapt7702Helper.valueForNFTShield(nftTokenData); + const addressData: AddressData = decodeAddress(recipientAddress); + const shieldNFT = new ShieldNoteNFT( + addressData.masterPublicKey, + random, + value, + nftTokenData, + ); + + // Random private key for Relay Adapt shield. + const shieldPrivateKey = ByteUtils.hexToBytes(ByteUtils.randomHex(32)); + + return shieldNFT.serialize(shieldPrivateKey, addressData.viewingPublicKey); + }), + ); + } + + private static valueForNFTShield(nftTokenData: NFTTokenData): bigint { + switch (nftTokenData.tokenType) { + case TokenType.ERC721: + return ERC721_NOTE_VALUE; + case TokenType.ERC1155: + return 0n; // 0n will automatically shield entire balance. + } + throw new Error('Unhandled NFT token type.'); + } + + /** + * Format action data field for relay call. + */ + static getActionData( + random: string, + requireSuccess: boolean, + calls: ContractTransaction[], + minGasLimit: bigint, + ): RelayAdapt7702.ActionDataStruct; + static getActionData( + requireSuccess: boolean, + calls: ContractTransaction[], + minGasLimit: bigint, + ): RelayAdapt7702.ActionDataStruct; + static getActionData( + randomOrRequireSuccess: string | boolean, + callsOrRequireSuccess: ContractTransaction[] | boolean, + callsOrMinGasLimit: ContractTransaction[] | bigint, + maybeMinGasLimit?: bigint, + ): RelayAdapt7702.ActionDataStruct { + const requireSuccess = + typeof randomOrRequireSuccess === 'boolean' + ? randomOrRequireSuccess + : (callsOrRequireSuccess as boolean); + const calls = + typeof randomOrRequireSuccess === 'boolean' + ? (callsOrRequireSuccess as ContractTransaction[]) + : (callsOrMinGasLimit as ContractTransaction[]); + const minGasLimit = + typeof randomOrRequireSuccess === 'boolean' + ? (callsOrMinGasLimit as bigint) + : maybeMinGasLimit; + + if (minGasLimit == null) { + throw new Error('RelayAdapt7702Helper.getActionData missing minGasLimit.'); + } + + return { + requireSuccess, + minGasLimit, + calls: RelayAdapt7702Helper.formatCalls(calls), + }; + } + + /** + * Strips all unnecessary fields from populated transactions + * + * @param {object[]} calls - calls list + * @returns {object[]} formatted calls + */ + static formatCalls(calls: ContractTransaction[]): RelayAdapt7702.CallStruct[] { + return calls.map((call) => ({ + to: call.to || '', + data: call.data || '', + value: call.value ?? 0n, + })); + } +} + +export { RelayAdapt7702Helper }; diff --git a/src/contracts/relay-adapt/relay-adapt-types.ts b/src/contracts/relay-adapt/relay-adapt-types.ts new file mode 100644 index 00000000..a884889b --- /dev/null +++ b/src/contracts/relay-adapt/relay-adapt-types.ts @@ -0,0 +1,6 @@ +export { RelayAdapt } from '../../abi/typechain/RelayAdapt'; +export { RelayAdapt7702 } from '../../abi/typechain/RelayAdapt7702'; +export { Registry } from '../../abi/typechain/Registry'; +export { RelayAdapt7702__factory } from '../../abi/typechain/factories/RelayAdapt7702__factory'; +export { Registry__factory } from '../../abi/typechain/factories/Registry__factory'; +export { RelayAdapt__factory } from '../../abi/typechain/factories/RelayAdapt__factory'; diff --git a/src/contracts/relay-adapt/relay-adapt-versioned-smart-contracts.ts b/src/contracts/relay-adapt/relay-adapt-versioned-smart-contracts.ts index 73308f8e..3cb26501 100644 --- a/src/contracts/relay-adapt/relay-adapt-versioned-smart-contracts.ts +++ b/src/contracts/relay-adapt/relay-adapt-versioned-smart-contracts.ts @@ -1,16 +1,20 @@ -import { ContractTransaction, Provider, TransactionRequest } from 'ethers'; +import { ContractTransaction, Provider, TransactionRequest, Authorization } from 'ethers'; import { ContractStore } from '../contract-store'; import { Chain } from '../../models/engine-types'; import { TXIDVersion } from '../../models/poi-types'; import { ShieldRequestStruct } from '../../abi/typechain/RelayAdapt'; import { TransactionReceiptLog, TransactionStructV2, TransactionStructV3 } from '../../models'; import { RelayAdaptV2Contract } from './V2/relay-adapt-v2'; +import { RelayAdapt7702Contract } from './V2/relay-adapt-7702'; import { RelayAdaptV3Contract } from './V3/relay-adapt-v3'; export class RelayAdaptVersionedSmartContracts { - static getRelayAdaptContract(txidVersion: TXIDVersion, chain: Chain) { + static getRelayAdaptContract(txidVersion: TXIDVersion, chain: Chain, isRelayAdapt7702 = false) { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: { + if (isRelayAdapt7702) { + return ContractStore.relayAdapt7702Contracts.getOrThrow(null, chain); + } return ContractStore.relayAdaptV2Contracts.getOrThrow(null, chain); } case TXIDVersion.V3_PoseidonMerkle: { @@ -24,8 +28,25 @@ export class RelayAdaptVersionedSmartContracts { txidVersion: TXIDVersion, chain: Chain, shieldRequest: ShieldRequestStruct, + isRelayAdapt7702 = false, + authorization?: Authorization, + signature?: string, + random31Bytes?: string, + ephemeralAddress?: string, ): Promise { - return this.getRelayAdaptContract(txidVersion, chain).populateShieldBaseToken(shieldRequest); + if (isRelayAdapt7702) { + const contract7702 = ContractStore.relayAdapt7702Contracts.getOrThrow(null, chain); + return contract7702.populateShieldBaseToken( + shieldRequest, + authorization, + signature, + random31Bytes, + ephemeralAddress, + ); + } + return this.getRelayAdaptContract(txidVersion, chain, isRelayAdapt7702).populateShieldBaseToken( + shieldRequest, + ); } static populateUnshieldBaseToken( @@ -36,9 +57,26 @@ export class RelayAdaptVersionedSmartContracts { random31Bytes: string, useDummyProof: boolean, sendWithPublicWallet: boolean, + isRelayAdapt7702 = false, + authorization?: Authorization, + signature?: string, + ephemeralAddress?: string, ): Promise { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: { + if (isRelayAdapt7702) { + const contract7702 = ContractStore.relayAdapt7702Contracts.getOrThrow(null, chain); + return contract7702.populateUnshieldBaseToken( + transactions as TransactionStructV2[], + unshieldAddress, + random31Bytes, + useDummyProof, + sendWithPublicWallet, + authorization, + signature, + ephemeralAddress, + ); + } const contractV2 = ContractStore.relayAdaptV2Contracts.getOrThrow(null, chain); return contractV2.populateUnshieldBaseToken( transactions as TransactionStructV2[], @@ -70,9 +108,28 @@ export class RelayAdaptVersionedSmartContracts { isGasEstimate: boolean, isBroadcasterTransaction: boolean, minGasLimit?: bigint, + isRelayAdapt7702 = false, + authorization?: Authorization, + signature?: string, + ephemeralAddress?: string, ): Promise { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: { + if (isRelayAdapt7702) { + const contract7702 = ContractStore.relayAdapt7702Contracts.getOrThrow(null, chain); + return contract7702.populateCrossContractCalls( + unshieldTransactions as TransactionStructV2[], + crossContractCalls, + relayShieldRequests, + random31Bytes, + isGasEstimate, + isBroadcasterTransaction, + minGasLimit, + authorization, + signature, + ephemeralAddress, + ); + } const contractV2 = ContractStore.relayAdaptV2Contracts.getOrThrow(null, chain); return contractV2.populateCrossContractCalls( unshieldTransactions as TransactionStructV2[], @@ -107,9 +164,21 @@ export class RelayAdaptVersionedSmartContracts { unshieldAddress: string, random31Bytes: string, sendWithPublicWallet: boolean, + isRelayAdapt7702 = false, + ephemeralAddress?: string, ): Promise { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: { + if (isRelayAdapt7702) { + const contract7702 = ContractStore.relayAdapt7702Contracts.getOrThrow(null, chain); + return contract7702.getRelayAdaptParamsUnshieldBaseToken( + dummyUnshieldTransactions as TransactionStructV2[], + unshieldAddress, + random31Bytes, + sendWithPublicWallet, + ephemeralAddress, + ); + } const contractV2 = ContractStore.relayAdaptV2Contracts.getOrThrow(null, chain); return contractV2.getRelayAdaptParamsUnshieldBaseToken( dummyUnshieldTransactions as TransactionStructV2[], @@ -139,9 +208,23 @@ export class RelayAdaptVersionedSmartContracts { random: string, isBroadcasterTransaction: boolean, minGasLimit?: bigint, + isRelayAdapt7702 = false, + ephemeralAddress?: string, ): Promise { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: { + if (isRelayAdapt7702) { + const contract7702 = ContractStore.relayAdapt7702Contracts.getOrThrow(null, chain); + return contract7702.getRelayAdaptParamsCrossContractCalls( + dummyUnshieldTransactions as TransactionStructV2[], + crossContractCalls, + relayShieldRequests, + random, + isBroadcasterTransaction, + minGasLimit, + ephemeralAddress, + ); + } const contractV2 = ContractStore.relayAdaptV2Contracts.getOrThrow(null, chain); return contractV2.getRelayAdaptParamsCrossContractCalls( dummyUnshieldTransactions as TransactionStructV2[], @@ -171,9 +254,13 @@ export class RelayAdaptVersionedSmartContracts { txidVersion: TXIDVersion, provider: Provider, transaction: ContractTransaction | TransactionRequest, + isRelayAdapt7702 = false, ) { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: { + if (isRelayAdapt7702) { + return RelayAdapt7702Contract.estimateGasWithErrorHandler(provider, transaction); + } return RelayAdaptV2Contract.estimateGasWithErrorHandler(provider, transaction); } case TXIDVersion.V3_PoseidonMerkle: { @@ -183,9 +270,12 @@ export class RelayAdaptVersionedSmartContracts { throw new Error('Unsupported txidVersion'); } - static getRelayAdaptCallError(txidVersion: TXIDVersion, receiptLogs: TransactionReceiptLog[]) { + static getRelayAdaptCallError(txidVersion: TXIDVersion, receiptLogs: TransactionReceiptLog[], isRelayAdapt7702 = false) { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: { + if (isRelayAdapt7702) { + return RelayAdapt7702Contract.getRelayAdaptCallError(receiptLogs); + } return RelayAdaptV2Contract.getRelayAdaptCallError(receiptLogs); } case TXIDVersion.V3_PoseidonMerkle: { @@ -195,9 +285,12 @@ export class RelayAdaptVersionedSmartContracts { throw new Error('Unsupported txidVersion'); } - static parseRelayAdaptReturnValue(txidVersion: TXIDVersion, data: string) { + static parseRelayAdaptReturnValue(txidVersion: TXIDVersion, data: string, isRelayAdapt7702 = false) { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: { + if (isRelayAdapt7702) { + return RelayAdapt7702Contract.parseRelayAdaptReturnValue(data); + } return RelayAdaptV2Contract.parseRelayAdaptReturnValue(data); } case TXIDVersion.V3_PoseidonMerkle: { diff --git a/src/index.ts b/src/index.ts index 06a0261d..5dffa622 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export { SpendingKeyPair, SpendingPublicKey, ViewingKeyPair, + deriveEphemeralWallet, } from './key-derivation'; export * from './merkletree/merkletree'; export * from './validation'; diff --git a/src/key-derivation/__tests__/ephemeral-key.test.ts b/src/key-derivation/__tests__/ephemeral-key.test.ts new file mode 100644 index 00000000..3d5163c7 --- /dev/null +++ b/src/key-derivation/__tests__/ephemeral-key.test.ts @@ -0,0 +1,105 @@ +import { expect } from 'chai'; +import { + deriveEphemeralWallet, + deriveEphemeralWalletFromPathSuffix, + getEphemeralWalletBasePath, +} from '../ephemeral-key'; + +describe('Ephemeral Key Derivation', () => { + const mnemonic = 'test test test test test test test test test test test junk'; + const railgunIndex = 0; + const chainId = 1n; + + it('should derive a wallet with the correct path', () => { + const index = 0; + const wallet = deriveEphemeralWallet( + mnemonic, + railgunIndex, + chainId, + index, + ); + + expect(wallet).to.not.be.undefined; + expect(wallet.path).to.equal("m/44'/60'/0'/7702'/0'/1'/0'"); + expect(wallet.address).to.be.a('string'); + }); + + it('should derive different wallets for different indices', () => { + const wallet0 = deriveEphemeralWallet( + mnemonic, + railgunIndex, + chainId, + 0, + ); + const wallet1 = deriveEphemeralWallet( + mnemonic, + railgunIndex, + chainId, + 1, + ); + + expect(wallet0.address).to.not.equal(wallet1.address); + }); + + it('should derive different wallets for different chain IDs', () => { + const walletChain1 = deriveEphemeralWallet( + mnemonic, + railgunIndex, + 1n, + 0, + ); + const walletChain10 = deriveEphemeralWallet( + mnemonic, + railgunIndex, + 10n, + 0, + ); + + expect(walletChain1.address).to.not.equal(walletChain10.address); + }); + + it('should be deterministic', () => { + const walletA = deriveEphemeralWallet( + mnemonic, + railgunIndex, + chainId, + 5, + ); + const walletB = deriveEphemeralWallet( + mnemonic, + railgunIndex, + chainId, + 5, + ); + + expect(walletA.address).to.equal(walletB.address); + expect(walletA.privateKey).to.equal(walletB.privateKey); + }); + + it('should derive different wallets for different railgun wallet indices', () => { + const walletIndex0 = deriveEphemeralWallet(mnemonic, 0, chainId, 0); + const walletIndex1 = deriveEphemeralWallet(mnemonic, 1, chainId, 0); + + expect(walletIndex0.address).to.not.equal(walletIndex1.address); + }); + + it('should derive a wallet from a relative path suffix', () => { + const wallet = deriveEphemeralWalletFromPathSuffix( + mnemonic, + getEphemeralWalletBasePath(0, 1n), + "5'", + ); + + expect(wallet.path).to.equal("m/44'/60'/0'/7702'/0'/1'/5'"); + }); + + it('should reject an absolute path suffix', () => { + expect(() => + deriveEphemeralWalletFromPathSuffix( + mnemonic, + getEphemeralWalletBasePath(0, 1n), + "m/44'/60'/0'/7702'/0'/1'/5'", + ), + ).to.throw('Ephemeral wallet derivation path suffix must be relative.'); + }); +}); diff --git a/src/key-derivation/bech32.ts b/src/key-derivation/bech32.ts index 8eb23ec4..4a6d1e51 100644 --- a/src/key-derivation/bech32.ts +++ b/src/key-derivation/bech32.ts @@ -80,7 +80,7 @@ function decodeAddress(address: string): AddressData { throw new Error('No address to decode'); } - const decoded = bech32m.decode(address, ADDRESS_LENGTH_LIMIT); + const decoded = bech32m.decode(address as any, ADDRESS_LENGTH_LIMIT); if (decoded.prefix !== PREFIX) { throw new Error('Invalid address prefix'); diff --git a/src/key-derivation/ephemeral-key.ts b/src/key-derivation/ephemeral-key.ts new file mode 100644 index 00000000..89aa2614 --- /dev/null +++ b/src/key-derivation/ephemeral-key.ts @@ -0,0 +1,66 @@ +import { HDNodeWallet } from 'ethers'; + +const EPHEMERAL_DERIVATION_PATH_PREFIX = "m/44'/60'/0'/7702"; + +export const getEphemeralWalletBasePath = ( + railgunIndex: number, + chainId: bigint, +): string => { + return `${EPHEMERAL_DERIVATION_PATH_PREFIX}'/${railgunIndex}'/${chainId.toString(10)}'`; +}; + +export const getEphemeralWalletPathSuffix = ( + index: number, +): string => { + return `${index}'`; +}; + +const normalizeEphemeralWalletPathSuffix = (pathSuffix: string): string => { + const normalizedPathSuffix = pathSuffix.trim().replace(/^\/+|\/+$/g, ''); + + if (normalizedPathSuffix.length === 0) { + throw new Error('Invalid ephemeral wallet derivation path suffix.'); + } + + if (normalizedPathSuffix.startsWith("m/")) { + throw new Error('Ephemeral wallet derivation path suffix must be relative.'); + } + + const pathSegments = normalizedPathSuffix.split('/'); + if (pathSegments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { + throw new Error('Invalid ephemeral wallet derivation path suffix.'); + } + + return normalizedPathSuffix; +}; + +export const deriveEphemeralWalletFromPathSuffix = ( + mnemonic: string, + basePath: string, + pathSuffix: string, +): HDNodeWallet => { + const normalizedPathSuffix = normalizeEphemeralWalletPathSuffix(pathSuffix); + const normalizedBasePath = basePath.replace(/\/+$/g, ''); + const path = `${normalizedBasePath}/${normalizedPathSuffix}`; + return HDNodeWallet.fromPhrase(mnemonic, undefined, path); +}; + +/** + * Derives an ephemeral wallet for RelayAdapt7702 transactions. + * Uses path: m/44'/60'/0'/7702'/railgunIndex'/chainId'/index' + * @param mnemonic - User's mnemonic + * @param railgunIndex - Base RAILGUN wallet derivation index + * @param chainId - Chain ID for the ephemeral key + * @param index - Index for the ephemeral key (nonce) + * @returns HDNodeWallet + */ +export const deriveEphemeralWallet = ( + mnemonic: string, + railgunIndex: number, + chainId: bigint, + index: number, +): HDNodeWallet => { + const basePath = getEphemeralWalletBasePath(railgunIndex, chainId); + const pathSuffix = getEphemeralWalletPathSuffix(index); + return deriveEphemeralWalletFromPathSuffix(mnemonic, basePath, pathSuffix); +}; diff --git a/src/key-derivation/index.ts b/src/key-derivation/index.ts index 1eb0e166..a32b97bb 100644 --- a/src/key-derivation/index.ts +++ b/src/key-derivation/index.ts @@ -3,3 +3,4 @@ export * from './bech32'; export * from './bip32'; export * from './bip39'; export * from './wallet-node'; +export * from './ephemeral-key'; diff --git a/src/models/index.ts b/src/models/index.ts index be606193..705cbb7e 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -14,3 +14,4 @@ export { export * from './wallet-types'; export * from './prover-types'; export * from './typechain-types'; +export * from './relay-adapt-types'; diff --git a/src/models/relay-adapt-types.ts b/src/models/relay-adapt-types.ts new file mode 100644 index 00000000..23fbb1b2 --- /dev/null +++ b/src/models/relay-adapt-types.ts @@ -0,0 +1,11 @@ +import { Authorization } from 'ethers'; +import { TransactionStructV2 } from './transaction-types'; +import { RelayAdapt7702 } from '../abi/typechain/RelayAdapt7702'; + +export interface RelayAdapt7702Request { + transactions: TransactionStructV2[]; + actionData: RelayAdapt7702.ActionDataStruct; + authorization: Authorization; + executionSignature: string; + ephemeralAddress: string; +} diff --git a/src/models/typechain-types.ts b/src/models/typechain-types.ts index 9e1f2871..28b3f911 100644 --- a/src/models/typechain-types.ts +++ b/src/models/typechain-types.ts @@ -3,5 +3,7 @@ import { ShieldRequestStruct, CommitmentCiphertextStructOutput, } from '../abi/typechain/RailgunSmartWallet'; +import { RelayAdapt } from '../abi/typechain/RelayAdapt'; +import { RelayAdapt__factory } from '../abi/typechain/factories/RelayAdapt__factory'; -export { TransactionStruct, ShieldRequestStruct, CommitmentCiphertextStructOutput }; +export { TransactionStruct, ShieldRequestStruct, CommitmentCiphertextStructOutput, RelayAdapt, RelayAdapt__factory }; diff --git a/src/railgun-engine.ts b/src/railgun-engine.ts index a86ce26a..a495ef3f 100644 --- a/src/railgun-engine.ts +++ b/src/railgun-engine.ts @@ -3,6 +3,11 @@ import EventEmitter from 'events'; import { FallbackProvider } from 'ethers'; import { RailgunSmartWalletContract } from './contracts/railgun-smart-wallet/V2/railgun-smart-wallet'; import { RelayAdaptV2Contract } from './contracts/relay-adapt/V2/relay-adapt-v2'; +import { RelayAdapt7702Contract } from './contracts/relay-adapt/V2/relay-adapt-7702'; +import { + RegistryContract, + REGISTRY_NAME_RELAY_ADAPT_7702, +} from './contracts/relay-adapt/V2/registry'; import { Database, DatabaseNamespace } from './database/database'; import { Prover } from './prover/prover'; import { encodeAddress, decodeAddress } from './key-derivation/bech32'; @@ -641,8 +646,11 @@ class RailgunEngine extends EventEmitter { const { tree: latestTree } = await utxoMerkletree.getLatestTreeAndIndex(); // check roots for trees up to this tree on 'scan' for (let treeIndex = 0; treeIndex <= latestTree; treeIndex += 1) { + // eslint-disable-next-line no-await-in-loop const index = await utxoMerkletree.getLatestIndexForTree(treeIndex); + // eslint-disable-next-line no-await-in-loop const root = await utxoMerkletree.getRoot(treeIndex); + // eslint-disable-next-line no-await-in-loop const isValid = await RailgunEngine.validateMerkleroot( txidVersion, chain, @@ -1518,6 +1526,8 @@ class RailgunEngine extends EventEmitter { deploymentBlocks: Record, poiLaunchBlock: Optional, supportsV3: boolean, + relayAdapt7702ContractAddress?: string, + railgunRegistryContractAddress?: string, ) { EngineDebug.log(`loadNetwork: ${chain.type}:${chain.id}`); @@ -1564,6 +1574,8 @@ class RailgunEngine extends EventEmitter { ); const hasSmartWalletContract = ContractStore.railgunSmartWalletContracts.has(null, chain); const hasRelayAdaptV2Contract = ContractStore.relayAdaptV2Contracts.has(null, chain); + const hasRelayAdapt7702Contract = ContractStore.relayAdapt7702Contracts.has(null, chain); + const hasRegistryContract = ContractStore.railgunRegistryContract.has(null, chain); const hasPoseidonMerkleAccumulatorV3Contract = ContractStore.poseidonMerkleAccumulatorV3Contracts.has(null, chain); const hasPoseidonMerkleVerifierV3Contract = ContractStore.poseidonMerkleVerifierV3Contracts.has( @@ -1575,6 +1587,8 @@ class RailgunEngine extends EventEmitter { hasAnyMerkletree || hasSmartWalletContract || hasRelayAdaptV2Contract || + hasRelayAdapt7702Contract || + hasRegistryContract || hasPoseidonMerkleAccumulatorV3Contract || hasPoseidonMerkleVerifierV3Contract || hasTokenVaultV3Contract @@ -1601,6 +1615,22 @@ class RailgunEngine extends EventEmitter { new RelayAdaptV2Contract(relayAdaptV2ContractAddress, defaultProvider), ); + if (isDefined(relayAdapt7702ContractAddress)) { + ContractStore.relayAdapt7702Contracts.set( + null, + chain, + new RelayAdapt7702Contract(relayAdapt7702ContractAddress, defaultProvider), + ); + } + + if (isDefined(railgunRegistryContractAddress)) { + ContractStore.railgunRegistryContract.set( + null, + chain, + new RegistryContract(railgunRegistryContractAddress, defaultProvider), + ); + } + if (supportsV3) { ContractStore.poseidonMerkleAccumulatorV3Contracts.set( null, @@ -2306,6 +2336,36 @@ class RailgunEngine extends EventEmitter { return shieldCommitments; } + /** + * Get RelayAdapt7702 contract address + * @param chain - chain type/id + * @returns address + */ + getRelayAdapt7702ContractAddress(chain: Chain): Optional { + const contract = ContractStore.relayAdapt7702Contracts.get(null, chain); + return contract?.address; + } + + /** + * Validate RelayAdapt7702 contract address + * @param chain - chain type/id + * @param address - address to validate + * @returns true if valid + */ + async validateRelayAdapt7702Address(chain: Chain, address: string): Promise { + const registry = ContractStore.railgunRegistryContract.get(null, chain); + if (!registry) { + return false; + } + + try { + const registeredAddress = await registry.getContractAddress(REGISTRY_NAME_RELAY_ADAPT_7702); + return registeredAddress.toLowerCase() === address.toLowerCase(); + } catch { + return false; + } + } + // Top-level exports: static encodeAddress = encodeAddress; @@ -2315,6 +2375,10 @@ class RailgunEngine extends EventEmitter { railgunSmartWalletContracts = ContractStore.railgunSmartWalletContracts; relayAdaptV2Contracts = ContractStore.relayAdaptV2Contracts; + + relayAdapt7702Contracts = ContractStore.relayAdapt7702Contracts; + + railgunRegistryContract = ContractStore.railgunRegistryContract; } export { RailgunEngine }; diff --git a/src/test/config-overrides.test.ts b/src/test/config-overrides.test.ts index ff8b4c56..cfc56604 100644 --- a/src/test/config-overrides.test.ts +++ b/src/test/config-overrides.test.ts @@ -1 +1,22 @@ -export default {}; +export const overrides = { + "contracts": { + "delegator": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707", + "governorRewardsImplementation": "", + "governorRewardsProxy": "", + "implementation": "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", + "proxy": "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", + "proxyAdmin": "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6", + "rail": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0", + "staking": "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", + "testERC20": "0x276C216D241856199A83bf27b2286659e5b877D3", + "testERC721": "0x3347B4d90ebe72BeFb30444C9966B2B990aE9FcB", + "treasuryImplementation": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", + "treasuryProxy": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + "voting": "0x0165878A594ca255338adfa4d48449f69242Eb8F", + "weth9": "0xA7c59f010700930003b33aB25a7a0679C860f29c", + "relayAdapt": "0xfaAddC93baf78e89DCf37bA67943E1bE8F37Bb8c", + "relayAdapt7702": "0x5bf5b11053e734690269C6B9D438F8C9d48F528A", + "railgunRegistry": "0x3155755b79aA083bd953911C92705B7aA82a18F9", + "adapt7702Deployer": "0x3155755b79aA083bd953911C92705B7aA82a18F9" + } +}; diff --git a/src/test/config.test.ts b/src/test/config.test.ts index cce5bcbb..05537350 100644 --- a/src/test/config.test.ts +++ b/src/test/config.test.ts @@ -20,6 +20,8 @@ let config = { voting: '0x0165878A594ca255338adfa4d48449f69242Eb8F', weth9: '0xA7c59f010700930003b33aB25a7a0679C860f29c', relayAdapt: '0xfaAddC93baf78e89DCf37bA67943E1bE8F37Bb8c', + relayAdapt7702: '0x5bf5b11053e734690269C6B9D438F8C9d48F528A', + adapt7702Deployer: '0x3155755b79aA083bd953911C92705B7aA82a18F9', // V3 poseidonMerkleAccumulatorV3: '0x2B0d36FACD61B71CC05ab8F3D2355ec3631C0dd5', @@ -32,7 +34,14 @@ try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, global-require, @typescript-eslint/no-var-requires const { overrides } = require('./config-overrides.test'); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - config = { ...config, ...overrides }; + config = { + ...config, + ...overrides, + contracts: { + ...config.contracts, + ...(overrides?.contracts ?? {}), + }, + }; // eslint-disable-next-line no-empty } catch {} diff --git a/src/transaction/__tests__/eip7702.test.ts b/src/transaction/__tests__/eip7702.test.ts new file mode 100644 index 00000000..e8c00c01 --- /dev/null +++ b/src/transaction/__tests__/eip7702.test.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import { Wallet, recoverAddress, keccak256, encodeRlp, getBytes, toBeHex } from 'ethers'; +import { signEIP7702Authorization } from '../eip7702'; + +function toRlpInteger(value: number): string | Uint8Array { + if (value === 0) { + return new Uint8Array(0); + } + return toBeHex(value); +} + +describe('EIP-7702 Signing', () => { + it('should sign and recover correctly', async () => { + const signer = Wallet.createRandom(); + const contractAddress = '0x1234567890123456789012345678901234567890'; + const chainId = 1n; + const nonce = 0; + + const auth = await signEIP7702Authorization(signer, contractAddress, chainId, nonce); + + // Reconstruct hash + const rlpEncoded = encodeRlp([ + toRlpInteger(Number(chainId)), + contractAddress, + toRlpInteger(nonce) + ]); + const payload = new Uint8Array([0x05, ...getBytes(rlpEncoded)]); + const hash = keccak256(payload); + + const recovered = recoverAddress(hash, auth.signature); + + expect(recovered).to.equal(signer.address); + }); +}); diff --git a/src/transaction/__tests__/relay-adapt-7702-signature.test.ts b/src/transaction/__tests__/relay-adapt-7702-signature.test.ts new file mode 100644 index 00000000..c1d271f9 --- /dev/null +++ b/src/transaction/__tests__/relay-adapt-7702-signature.test.ts @@ -0,0 +1,69 @@ +import { expect } from 'chai'; +import { Wallet, verifyTypedData } from 'ethers'; +import { getExecutePayloadHash, signExecutionAuthorization } from '../relay-adapt-7702-signature'; +import { TransactionStructV2 } from '../../models/transaction-types'; +import { RelayAdapt7702 } from '../../abi/typechain/RelayAdapt7702'; +import { TXIDVersion } from '../../models/poi-types'; + +describe('RelayAdapt7702 Execution Signature', () => { + it('should sign and recover correctly', async () => { + const signer = Wallet.createRandom(); + const chainId = 1; + + const mockTransaction: TransactionStructV2 = { + txidVersion: TXIDVersion.V2_PoseidonMerkle, + proof: { + a: { x: 1n, y: 2n }, + b: { x: [3n, 4n], y: [5n, 6n] }, + c: { x: 7n, y: 8n }, + }, + merkleRoot: '0x' + '0'.repeat(64), + nullifiers: ['0x' + '0'.repeat(64)], + commitments: ['0x' + '0'.repeat(64)], + boundParams: { + treeNumber: 0, + minGasPrice: 0n, + unshield: 0, + chainID: 1n, + adaptContract: '0x' + '0'.repeat(40), + adaptParams: '0x' + '0'.repeat(64), + commitmentCiphertext: [], + }, + unshieldPreimage: { + npk: '0x' + '0'.repeat(64), + token: { + tokenType: 0, + tokenAddress: '0x' + '0'.repeat(40), + tokenSubID: 0n, + }, + value: 0n, + }, + }; + + const mockActionData: RelayAdapt7702.ActionDataStruct = { + requireSuccess: true, + minGasLimit: 100000n, + calls: [], + }; + + const signature = await signExecutionAuthorization(signer, [mockTransaction], mockActionData, chainId); + + const recovered = verifyTypedData( + { + name: 'RelayAdapt7702', + version: '1', + chainId, + verifyingContract: signer.address, + }, + { + Execute: [{ name: 'payloadHash', type: 'bytes32' }], + }, + { + payloadHash: getExecutePayloadHash([mockTransaction], mockActionData), + }, + signature, + ); + + expect(recovered).to.equal(signer.address); + }); +}); diff --git a/src/transaction/eip7702.ts b/src/transaction/eip7702.ts new file mode 100644 index 00000000..d1db3336 --- /dev/null +++ b/src/transaction/eip7702.ts @@ -0,0 +1,27 @@ +import { HDNodeWallet, Wallet, Authorization } from 'ethers'; + +/** + * Signs an EIP-7702 Authorization Tuple using ethers native methods. + * @param signer - The ephemeral key signer + * @param contractAddress - The address to delegate to (RelayAdapt7702) + * @param chainId - Chain ID (optional - will be auto-populated if not provided) + * @param nonce - Nonce (optional - will be auto-populated if not provided) + * @returns Authorization + */ +export const signEIP7702Authorization = async ( + signer: HDNodeWallet | Wallet, + contractAddress: string, + chainId?: bigint, + nonce?: number, +): Promise => { + // Use ethers 6.14.3+ native 7702 authorization signing + const authRequest = await signer.populateAuthorization({ + address: contractAddress, + ...(chainId !== undefined && { chainId }), + ...(nonce !== undefined && { nonce: BigInt(nonce) }), + }); + + return signer.authorize(authRequest); +}; + + diff --git a/src/transaction/index.ts b/src/transaction/index.ts index ec1e9f9e..a80da1ff 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -1,3 +1,5 @@ // Note: we purposefully do not export everything, in order to reduce the number of public APIs export * from './transaction-batch'; export * from './railgun-txid'; +export * from './eip7702'; +export * from './relay-adapt-7702-signature'; diff --git a/src/transaction/relay-adapt-7702-signature.ts b/src/transaction/relay-adapt-7702-signature.ts new file mode 100644 index 00000000..5c8e342f --- /dev/null +++ b/src/transaction/relay-adapt-7702-signature.ts @@ -0,0 +1,71 @@ +import { HDNodeWallet, Wallet, AbiCoder, keccak256 } from 'ethers'; +import { TransactionStructV2, TransactionStructV3 } from '../models/transaction-types'; +import { RelayAdapt7702__factory } from '../abi/typechain/factories/RelayAdapt7702__factory'; +import { RelayAdapt7702 } from '../abi/typechain/RelayAdapt7702'; + +const iface = RelayAdapt7702__factory.createInterface(); +const executeFunc = iface.getFunction('execute'); +if (!executeFunc) { + throw new Error('RelayAdapt7702: execute function not found in ABI'); +} + +// inputs[0] is Transaction[] +const transactionArrayType = executeFunc.inputs[0]; +if (transactionArrayType.type !== 'tuple[]' || !transactionArrayType.arrayChildren) { + throw new Error('RelayAdapt7702: execute input[0] is not Transaction[]'); +} +export const TRANSACTION_STRUCT_ABI = transactionArrayType.arrayChildren.format('full'); + +// inputs[1] is ActionData +const actionDataType = executeFunc.inputs[1]; +if (actionDataType.type !== 'tuple') { + throw new Error('RelayAdapt7702: execute input[1] is not ActionData'); +} +export const ACTION_DATA_STRUCT_ABI = actionDataType.format('full'); + +export const ZERO_7702_ADAPT_PARAMS = '0x0000000000000000000000000000000000000000000000000000000000000000'; + +export const getExecutePayloadHash = ( + transactions: (TransactionStructV2 | TransactionStructV3)[], + actionData: RelayAdapt7702.ActionDataStruct, +): string => { + const abiCoder = AbiCoder.defaultAbiCoder(); + const encoded = abiCoder.encode( + [`${TRANSACTION_STRUCT_ABI}[]`, ACTION_DATA_STRUCT_ABI], + [transactions, actionData], + ); + + return keccak256(encoded); +}; + +/** + * Signs the execution payload for RelayAdapt7702. + * Payload: keccak256(abi.encode(transactions, actionData)) + * Signed as EIP-712 typed data using the ephemeral address as verifyingContract. + * @param signer - The ephemeral key signer + * @param transactions - Railgun transactions + * @param actionData - Action data + * @param chainId - Chain ID + * @returns Signature string + */ +export const signExecutionAuthorization = async ( + signer: HDNodeWallet | Wallet, + transactions: (TransactionStructV2 | TransactionStructV3)[], + actionData: RelayAdapt7702.ActionDataStruct, + chainId: number +): Promise => { + const domain = { + name: 'RelayAdapt7702', + version: '1', + chainId, + verifyingContract: signer.address, + }; + + const types = { + Execute: [{ name: 'payloadHash', type: 'bytes32' }], + }; + + return signer.signTypedData(domain, types, { + payloadHash: getExecutePayloadHash(transactions, actionData), + }); +}; diff --git a/src/validation/__tests__/relay-adapt-7702-validator.test.ts b/src/validation/__tests__/relay-adapt-7702-validator.test.ts new file mode 100644 index 00000000..4eae2d20 --- /dev/null +++ b/src/validation/__tests__/relay-adapt-7702-validator.test.ts @@ -0,0 +1,18 @@ +import { expect } from 'chai'; +import { TRANSACTION_STRUCT_ABI, ACTION_DATA_STRUCT_ABI } from '../../transaction/relay-adapt-7702-signature'; +import { ParamType } from 'ethers'; + +describe('RelayAdapt7702Validator', () => { + it('should have valid ABI strings derived from TypeChain', () => { + expect(TRANSACTION_STRUCT_ABI).to.be.a('string'); + + expect(ACTION_DATA_STRUCT_ABI).to.be.a('string'); + + // Verify they are valid ParamTypes + const transactionType = ParamType.from(TRANSACTION_STRUCT_ABI); + expect(transactionType.baseType).to.equal('tuple'); + + const actionDataType = ParamType.from(ACTION_DATA_STRUCT_ABI); + expect(actionDataType.baseType).to.equal('tuple'); + }); +}); diff --git a/src/validation/extract-transaction-data-v2.ts b/src/validation/extract-transaction-data-v2.ts index ab75d802..1a04ce52 100644 --- a/src/validation/extract-transaction-data-v2.ts +++ b/src/validation/extract-transaction-data-v2.ts @@ -1,5 +1,5 @@ import { Contract, ContractTransaction } from 'ethers'; -import { ABIRailgunSmartWallet, ABIRelayAdapt } from '../abi/abi'; +import { ABIRailgunSmartWallet, ABIRelayAdapt, ABIRelayAdapt7702 } from '../abi/abi'; import { Chain } from '../models/engine-types'; import { TransactionStructOutput } from '../abi/typechain/RailgunSmartWallet'; import { AddressData } from '../key-derivation'; @@ -20,6 +20,7 @@ import { TXIDVersion } from '../models/poi-types'; enum TransactionName { RailgunSmartWallet = 'transact', RelayAdapt = 'relay', + RelayAdapt7702 = 'execute' } const getABIForTransaction = (transactionName: TransactionName): Array => { @@ -28,6 +29,8 @@ const getABIForTransaction = (transactionName: TransactionName): Array => { return ABIRailgunSmartWallet; case TransactionName.RelayAdapt: return ABIRelayAdapt; + case TransactionName.RelayAdapt7702: + return ABIRelayAdapt7702; } throw new Error('Unsupported transactionName'); }; @@ -40,8 +43,11 @@ export const extractFirstNoteERC20AmountMapFromTransactionRequestV2 = ( receivingViewingPrivateKey: Uint8Array, receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, + useRelayAdapt7702: boolean = false, ): Promise> => { - const transactionName = useRelayAdapt + const transactionName = useRelayAdapt7702 + ? TransactionName.RelayAdapt7702 + : useRelayAdapt ? TransactionName.RelayAdapt : TransactionName.RailgunSmartWallet; @@ -64,8 +70,11 @@ export const extractRailgunTransactionDataFromTransactionRequestV2 = ( receivingViewingPrivateKey: Uint8Array, receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, + useRelayAdapt7702: boolean = false, ): Promise => { - const transactionName = useRelayAdapt + const transactionName = useRelayAdapt7702 + ? TransactionName.RelayAdapt7702 + : useRelayAdapt ? TransactionName.RelayAdapt : TransactionName.RailgunSmartWallet; diff --git a/src/validation/extract-transaction-data.ts b/src/validation/extract-transaction-data.ts index 1eeb09a7..c5d44714 100644 --- a/src/validation/extract-transaction-data.ts +++ b/src/validation/extract-transaction-data.ts @@ -21,6 +21,7 @@ export const extractFirstNoteERC20AmountMapFromTransactionRequest = ( receivingViewingPrivateKey: Uint8Array, receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, + useRelayAdapt7702: boolean = false, ) => { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: @@ -32,6 +33,7 @@ export const extractFirstNoteERC20AmountMapFromTransactionRequest = ( receivingViewingPrivateKey, receivingRailgunAddressData, tokenDataGetter, + useRelayAdapt7702, ); case TXIDVersion.V3_PoseidonMerkle: return extractFirstNoteERC20AmountMapFromTransactionRequestV3( @@ -55,6 +57,7 @@ export const extractRailgunTransactionDataFromTransactionRequest = ( receivingViewingPrivateKey: Uint8Array, receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, + useRelayAdapt7702: boolean = false, ) => { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: @@ -66,6 +69,7 @@ export const extractRailgunTransactionDataFromTransactionRequest = ( receivingViewingPrivateKey, receivingRailgunAddressData, tokenDataGetter, + useRelayAdapt7702, ); case TXIDVersion.V3_PoseidonMerkle: return extractRailgunTransactionDataFromTransactionRequestV3( diff --git a/src/validation/index.ts b/src/validation/index.ts index e5d8e6de..2daac825 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -1,2 +1,3 @@ // Note: we purposefully do not export everything, in order to reduce the number of public APIs export * from './poi-validation'; +export * from './relay-adapt-7702-validator'; diff --git a/src/validation/poi-validation.ts b/src/validation/poi-validation.ts index 5d1198cd..68e588d2 100644 --- a/src/validation/poi-validation.ts +++ b/src/validation/poi-validation.ts @@ -41,6 +41,7 @@ export class POIValidation { receivingViewingPrivateKey: Uint8Array, receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, + useRelayAdapt7702: boolean = false, ): Promise<{ isValid: boolean; error?: string; @@ -57,6 +58,7 @@ export class POIValidation { receivingViewingPrivateKey, receivingRailgunAddressData, tokenDataGetter, + useRelayAdapt7702, ); const activeListKeys = POI.getActiveListKeys(); diff --git a/src/validation/relay-adapt-7702-validator.ts b/src/validation/relay-adapt-7702-validator.ts new file mode 100644 index 00000000..eaed8241 --- /dev/null +++ b/src/validation/relay-adapt-7702-validator.ts @@ -0,0 +1,58 @@ +import { verifyTypedData, getBytes, keccak256, encodeRlp, toBeHex, recoverAddress, Authorization } from 'ethers'; +import { TransactionStructV2 } from '../models/transaction-types'; +import { RelayAdapt7702 } from '../abi/typechain/RelayAdapt7702'; +import { getExecutePayloadHash } from '../transaction/relay-adapt-7702-signature'; + +export class RelayAdapt7702Validator { + static validateAuthorization( + authorization: Authorization, + expectedContractAddress: string, + expectedChainId: number + ): string { + if (authorization.address.toLowerCase() !== expectedContractAddress.toLowerCase()) { + throw new Error('Authorization contract address mismatch'); + } + if (authorization.chainId !== BigInt(expectedChainId)) { + throw new Error('Authorization chain ID mismatch'); + } + + // Reconstruct payload + const rlpEncoded = encodeRlp([ + authorization.chainId === 0n ? new Uint8Array(0) : toBeHex(authorization.chainId), + authorization.address, + authorization.nonce === 0n ? new Uint8Array(0) : toBeHex(authorization.nonce) + ]); + const payload = new Uint8Array([0x05, ...getBytes(rlpEncoded)]); + const hash = keccak256(payload); + + // Recover address + return recoverAddress(hash, authorization.signature); + } + + static validateExecution( + transactions: TransactionStructV2[], + actionData: RelayAdapt7702.ActionDataStruct, + signature: string, + chainId: number, + expectedSigner: string + ): void { + const domain = { + name: 'RelayAdapt7702', + version: '1', + chainId, + verifyingContract: expectedSigner, + }; + + const types = { + Execute: [{ name: 'payloadHash', type: 'bytes32' }], + }; + + const recoveredAddress = verifyTypedData(domain, types, { + payloadHash: getExecutePayloadHash(transactions, actionData), + }, signature); + + if (recoveredAddress.toLowerCase() !== expectedSigner.toLowerCase()) { + throw new Error('Execution signature signer mismatch'); + } + } +} diff --git a/src/wallet/__tests__/ephemeral-key-manager.test.ts b/src/wallet/__tests__/ephemeral-key-manager.test.ts new file mode 100644 index 00000000..cb5f98ee --- /dev/null +++ b/src/wallet/__tests__/ephemeral-key-manager.test.ts @@ -0,0 +1,85 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import Sinon, { SinonStub } from 'sinon'; +import { HDNodeWallet } from 'ethers'; +import { Chain, ChainType } from '../../models/engine-types'; +import { RailgunWallet } from '../railgun-wallet'; +import { EphemeralKeyManager } from '../ephemeral-key-manager'; + +chai.use(chaiAsPromised); +const { expect } = chai; + +const MOCK_ENCRYPTION_KEY = '0x1234'; +const MOCK_MNEMONIC = 'test test test test test test test test test test test junk'; +const MOCK_CHAIN: Chain = { type: ChainType.EVM, id: 1 }; + +describe('engine ephemeral-key-manager', () => { + let getEphemeralWalletStub: SinonStub; + let getEphemeralKeyIndexStub: SinonStub; + let setEphemeralKeyIndexStub: SinonStub; + let getTransactionHistoryStub: SinonStub; + let manager: EphemeralKeyManager; + + beforeEach(() => { + const railgunWallet = { + getEphemeralWallet: async () => {}, + getEphemeralKeyIndex: async () => {}, + setEphemeralKeyIndex: async () => {}, + getTransactionHistory: async () => {}, + } as unknown as RailgunWallet; + + getEphemeralWalletStub = Sinon.stub(railgunWallet, 'getEphemeralWallet'); + getEphemeralKeyIndexStub = Sinon.stub(railgunWallet, 'getEphemeralKeyIndex'); + setEphemeralKeyIndexStub = Sinon.stub(railgunWallet, 'setEphemeralKeyIndex'); + getTransactionHistoryStub = Sinon.stub(railgunWallet, 'getTransactionHistory'); + + manager = new EphemeralKeyManager(railgunWallet, MOCK_ENCRYPTION_KEY); + }); + + it('Should get wallet at index', async () => { + const mockWallet = HDNodeWallet.fromPhrase(MOCK_MNEMONIC); + getEphemeralWalletStub.resolves(mockWallet); + + const wallet = await manager.getWallet(BigInt(MOCK_CHAIN.id), 0); + expect(wallet.address).to.equal(mockWallet.address); + expect(getEphemeralWalletStub.calledWith(MOCK_ENCRYPTION_KEY, BigInt(MOCK_CHAIN.id), 0)).to.be.true; + }); + + it('Should get next wallet and increment index', async () => { + const mockWallet = HDNodeWallet.fromPhrase(MOCK_MNEMONIC); + getEphemeralKeyIndexStub.resolves(5); + getEphemeralWalletStub.resolves(mockWallet); + setEphemeralKeyIndexStub.resolves(); + + const wallet = await manager.getNextWallet(BigInt(MOCK_CHAIN.id)); + expect(wallet.address).to.equal(mockWallet.address); + expect(getEphemeralKeyIndexStub.calledWith(BigInt(MOCK_CHAIN.id))).to.be.true; + expect(setEphemeralKeyIndexStub.calledWith(BigInt(MOCK_CHAIN.id), 6)).to.be.true; + expect(getEphemeralWalletStub.calledWith(MOCK_ENCRYPTION_KEY, BigInt(MOCK_CHAIN.id), 6)).to.be.true; + }); + + it('Should scan history and recover index', async () => { + const mockWallet0 = HDNodeWallet.fromPhrase(MOCK_MNEMONIC, undefined, "m/44'/60'/0'/7702/1'/0"); + const mockWallet1 = HDNodeWallet.fromPhrase(MOCK_MNEMONIC, undefined, "m/44'/60'/0'/7702/1'/1"); + const mockWallet2 = HDNodeWallet.fromPhrase(MOCK_MNEMONIC, undefined, "m/44'/60'/0'/7702/1'/2"); + + getEphemeralWalletStub.withArgs(MOCK_ENCRYPTION_KEY, BigInt(MOCK_CHAIN.id), 0).resolves(mockWallet0); + getEphemeralWalletStub.withArgs(MOCK_ENCRYPTION_KEY, BigInt(MOCK_CHAIN.id), 1).resolves(mockWallet1); + getEphemeralWalletStub.withArgs(MOCK_ENCRYPTION_KEY, BigInt(MOCK_CHAIN.id), 2).resolves(mockWallet2); + getEphemeralWalletStub.resolves(mockWallet2); + + getTransactionHistoryStub.resolves([ + { + unshieldTokenAmounts: [{ recipientAddress: mockWallet1.address }], + }, + ]); + + getEphemeralKeyIndexStub.resolves(0); + setEphemeralKeyIndexStub.resolves(); + + const recoveredIndex = await manager.scanHistoryForEphemeralIndex(MOCK_CHAIN, 100); + + expect(recoveredIndex).to.equal(2); + expect(setEphemeralKeyIndexStub.calledWith(BigInt(MOCK_CHAIN.id), 2)).to.be.true; + }); +}); diff --git a/src/wallet/__tests__/railgun-wallet.test.ts b/src/wallet/__tests__/railgun-wallet.test.ts index 9b65e197..0a2ec5bc 100644 --- a/src/wallet/__tests__/railgun-wallet.test.ts +++ b/src/wallet/__tests__/railgun-wallet.test.ts @@ -298,6 +298,91 @@ describe('railgun-wallet', () => { expect(decoded.chain).to.deep.equal(chain); }); + it('Should scope ephemeral key index by chain ID', async () => { + expect(await wallet.getEphemeralKeyIndex(1n)).to.equal(0); + expect(await wallet.getEphemeralKeyIndex(10n)).to.equal(0); + + await wallet.setEphemeralKeyIndex(1n, 3); + await wallet.setEphemeralKeyIndex(10n, 7); + + expect(await wallet.getEphemeralKeyIndex(1n)).to.equal(3); + expect(await wallet.getEphemeralKeyIndex(10n)).to.equal(7); + }); + + it('Should ratchet ephemeral key index for selected chain only', async () => { + await wallet.setEphemeralKeyIndex(1n, 2); + await wallet.setEphemeralKeyIndex(10n, 5); + + await wallet.ratchetEphemeralAddress(1n); + + expect(await wallet.getEphemeralKeyIndex(1n)).to.equal(3); + expect(await wallet.getEphemeralKeyIndex(10n)).to.equal(5); + }); + + it('Should keep base path when using injected ephemeral derivation suffix strategy', async () => { + wallet.setEphemeralWalletDerivationStrategy((index) => { + return `${index}'/99`; + }); + + const ephemeralWallet = await wallet.getEphemeralWallet(testEncryptionKey, 1n, 2); + + expect(ephemeralWallet.path).to.equal("m/44'/60'/0'/7702'/0'/1'/2'/99"); + }); + + it('Should isolate suffixed ephemeral key index paths from the default path', async () => { + await wallet.setEphemeralKeyIndex(1n, 3); + + wallet.setEphemeralSignerProvider({ + getPathSuffix: (index) => `${index}'/99`, + getDBPathSuffix: () => ['custom'], + }); + + expect(await wallet.getEphemeralKeyIndex(1n)).to.equal(0); + + await wallet.setEphemeralKeyIndex(1n, 7); + expect(await wallet.getEphemeralKeyIndex(1n)).to.equal(7); + + wallet.setEphemeralSignerProvider({ + getPathSuffix: (index) => `${index}'`, + getDBPathSuffix: () => [], + }); + + expect(await wallet.getEphemeralKeyIndex(1n)).to.equal(3); + }); + + it('Should reject invalid ephemeral key index path suffixes', async () => { + wallet.setEphemeralSignerProvider({ + getPathSuffix: (index) => `${index}'`, + getDBPathSuffix: () => [''], + }); + + await expect(wallet.getEphemeralKeyIndex(1n)).to.be.rejectedWith( + 'Invalid ephemeral key index DB path suffix.', + ); + }); + + it('Should reject non-string injected derivation path suffixes', async () => { + wallet.setEphemeralSignerProvider({ + getPathSuffix: () => 123 as unknown as string, + getDBPathSuffix: () => [], + }); + + await expect(wallet.getEphemeralWallet(testEncryptionKey, 1n, 2)).to.be.rejectedWith( + 'Invalid ephemeral wallet derivation path suffix.', + ); + }); + + it('Should reject non-string injected DB path suffix segments', async () => { + wallet.setEphemeralSignerProvider({ + getPathSuffix: (index) => `${index}'`, + getDBPathSuffix: () => [123 as unknown as string], + }); + + await expect(wallet.getEphemeralKeyIndex(1n)).to.be.rejectedWith( + 'Invalid ephemeral key index DB path suffix.', + ); + }); + it('Should get empty wallet details', async () => { const walletDetails = await wallet.getWalletDetails(txidVersion, chain); expect(walletDetails).to.deep.equal({ diff --git a/src/wallet/abstract-wallet.ts b/src/wallet/abstract-wallet.ts index 579d930c..515c6afe 100644 --- a/src/wallet/abstract-wallet.ts +++ b/src/wallet/abstract-wallet.ts @@ -2118,6 +2118,7 @@ abstract class AbstractWallet extends EventEmitter { transactionRequest: ContractTransaction, useRelayAdapt: boolean, pois: PreTransactionPOIsPerTxidLeafPerList, + useRelayAdapt7702: boolean, ): Promise<{ isValid: boolean; error?: string; @@ -2134,6 +2135,7 @@ abstract class AbstractWallet extends EventEmitter { this.getViewingKeyPair().privateKey, this.addressKeys, this.tokenDataGetter, + useRelayAdapt7702, ); } @@ -2318,6 +2320,7 @@ abstract class AbstractWallet extends EventEmitter { transactionRequest: ContractTransaction, useRelayAdapt: boolean, contractAddress: string, + useRelayAdapt7702: boolean = false, ) { return extractFirstNoteERC20AmountMapFromTransactionRequest( txidVersion, @@ -2328,6 +2331,7 @@ abstract class AbstractWallet extends EventEmitter { this.getViewingKeyPair().privateKey, this.addressKeys, this.tokenDataGetter, + useRelayAdapt7702 ); } diff --git a/src/wallet/ephemeral-key-manager.ts b/src/wallet/ephemeral-key-manager.ts new file mode 100644 index 00000000..6de91a7e --- /dev/null +++ b/src/wallet/ephemeral-key-manager.ts @@ -0,0 +1,79 @@ +import { HDNodeWallet } from 'ethers'; +import { Chain } from '../models/engine-types'; +import { RailgunWallet } from './railgun-wallet'; + +export class EphemeralKeyManager { + private railgunWallet: RailgunWallet; + private encryptionKey: string; + + constructor(railgunWallet: RailgunWallet, encryptionKey: string) { + this.railgunWallet = railgunWallet; + this.encryptionKey = encryptionKey; + } + + async getWallet(chainId: bigint, index: number): Promise { + return this.railgunWallet.getEphemeralWallet( + this.encryptionKey, + chainId, + index, + ); + } + + async getCurrentWallet(chainId: bigint): Promise { + const index = await this.railgunWallet.getEphemeralKeyIndex(chainId); + return this.getWallet(chainId, index); + } + + async getNextWallet(chainId: bigint): Promise { + const currentIndex = await this.railgunWallet.getEphemeralKeyIndex(chainId); + const nextIndex = currentIndex + 1; + await this.railgunWallet.setEphemeralKeyIndex(chainId, nextIndex); + return this.getWallet(chainId, nextIndex); + } + + async scanHistoryForEphemeralIndex( + chain: Chain, + scanLimit = 100, + ): Promise { + const chainId = BigInt(chain.id); + const history = await this.railgunWallet.getTransactionHistory(chain, undefined); + + const unshieldRecipients = new Set(); + for (const entry of history) { + for (const unshield of entry.unshieldTokenAmounts) { + unshieldRecipients.add(unshield.recipientAddress.toLowerCase()); + } + } + + if (unshieldRecipients.size === 0) { + return 0; + } + + let maxUsedIndex = -1; + let currentIndex = 0; + let gapCount = 0; + const gapLimit = 20; + + while (currentIndex < scanLimit && gapCount < gapLimit) { + // eslint-disable-next-line no-await-in-loop + const wallet = await this.getWallet(chainId, currentIndex); + const address = wallet.address.toLowerCase(); + + if (unshieldRecipients.has(address)) { + maxUsedIndex = currentIndex; + gapCount = 0; + } else { + gapCount += 1; + } + currentIndex += 1; + } + + const nextIndex = maxUsedIndex + 1; + const storedIndex = await this.railgunWallet.getEphemeralKeyIndex(chainId); + if (nextIndex > storedIndex) { + await this.railgunWallet.setEphemeralKeyIndex(chainId, nextIndex); + } + + return nextIndex; + } +} diff --git a/src/wallet/index.ts b/src/wallet/index.ts index f3070d25..bd116155 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -1,5 +1,6 @@ // Note: we purposefully do not export everything, in order to reduce the number of public APIs export * from './abstract-wallet'; export * from './hardware-wallet'; +export * from './ephemeral-key-manager'; export * from './railgun-wallet'; export * from './view-only-wallet'; diff --git a/src/wallet/railgun-wallet.ts b/src/wallet/railgun-wallet.ts index 492f2a56..ea5b317d 100644 --- a/src/wallet/railgun-wallet.ts +++ b/src/wallet/railgun-wallet.ts @@ -1,7 +1,14 @@ import { Signature } from '@railgun-community/circomlibjs'; import { poseidon } from '../utils/poseidon'; import { Database } from '../database/database'; -import { deriveNodes, SpendingKeyPair, WalletNode } from '../key-derivation/wallet-node'; +import { + deriveNodes, + SpendingKeyPair, + WalletNode, + deriveEphemeralWalletFromPathSuffix, + getEphemeralWalletBasePath, + getEphemeralWalletPathSuffix, +} from '../key-derivation'; import { WalletData } from '../models/wallet-types'; import { ByteUtils } from '../utils/bytes'; import { sha256 } from '../utils/hash'; @@ -10,8 +17,90 @@ import { Mnemonic } from '../key-derivation/bip39'; import { PublicInputsRailgun } from '../models'; import { signEDDSA } from '../utils/keys-utils'; import { Prover } from '../prover/prover'; +import { HDNodeWallet, Authorization } from 'ethers'; +import { RelayAdapt7702Helper } from '../contracts/relay-adapt/relay-adapt-7702-helper'; +import { TransactionStructV2, TransactionStructV3 } from '../models/transaction-types'; +import { RelayAdapt7702 } from '../abi/typechain/RelayAdapt7702'; + +export type EphemeralWalletDerivationStrategy = ( + index: number, +) => string; + +// Optional namespace segments appended after the engine-owned chain path. +export type EphemeralWalletDBPathStrategy = () => string[]; + +const EPHEMERAL_KEY_INDEX_PROVIDER_NAMESPACE = 'provider'; + +const getEphemeralKeyIndexBaseDBPath = ( + id: string, + chainId: bigint, +): string[] => { + return [id, 'ephemeral_index', chainId.toString(10)]; +}; + +const normalizeEphemeralKeyIndexDBPathSuffix = ( + dbPathSuffix: string[], +): string[] => { + if (dbPathSuffix.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { + throw new Error('Invalid ephemeral key index DB path suffix.'); + } + + return dbPathSuffix; +}; + +const getEphemeralKeyIndexDBPath = ( + id: string, + chainId: bigint, + dbPathSuffix: string[], +): string[] => { + const normalizedDBPathSuffix = normalizeEphemeralKeyIndexDBPathSuffix(dbPathSuffix); + if (normalizedDBPathSuffix.length === 0) { + return getEphemeralKeyIndexBaseDBPath(id, chainId); + } + + return [ + ...getEphemeralKeyIndexBaseDBPath(id, chainId), + EPHEMERAL_KEY_INDEX_PROVIDER_NAMESPACE, + ...normalizedDBPathSuffix, + ]; +}; + +const getEphemeralSignerPathSuffix = ( + provider: EphemeralSignerProvider, + index: number, +): string => { + const pathSuffix = provider.getPathSuffix(index); + if (typeof pathSuffix !== 'string') { + throw new Error('Invalid ephemeral wallet derivation path suffix.'); + } + + return pathSuffix; +}; + +const getEphemeralSignerDBPathSuffix = ( + provider: EphemeralSignerProvider, +): string[] => { + const dbPathSuffix = provider.getDBPathSuffix(); + if (!Array.isArray(dbPathSuffix) || dbPathSuffix.some((segment) => typeof segment !== 'string')) { + throw new Error('Invalid ephemeral key index DB path suffix.'); + } + + return dbPathSuffix; +}; + +export interface EphemeralSignerProvider { + getPathSuffix: EphemeralWalletDerivationStrategy; + getDBPathSuffix: EphemeralWalletDBPathStrategy; +} + +const defaultEphemeralSignerProvider: EphemeralSignerProvider = { + getPathSuffix: getEphemeralWalletPathSuffix, + getDBPathSuffix: () => [], +}; class RailgunWallet extends AbstractWallet { + ephemeralWalletOverride: HDNodeWallet | undefined; + private ephemeralSignerProvider: EphemeralSignerProvider = defaultEphemeralSignerProvider; /** * Load encrypted spending key Node from database * Spending key should be kept private and only accessed on demand @@ -35,6 +124,154 @@ class RailgunWallet extends AbstractWallet { return signEDDSA(spendingKeyPair.privateKey, msg); } + /** + * Get ephemeral wallet for RelayAdapt7702 + * @param {string} encryptionKey - encryption key to use with database + * @param {bigint} chainId - Chain ID for the ephemeral key + * @param {number} index - index of derivation path + * @returns {Promise} + */ + async getEphemeralWallet( + encryptionKey: string, + chainId: bigint, + index: number, + ): Promise { + const { mnemonic, index: railgunIndex } = (await AbstractWallet.read( + this.db, + this.id, + encryptionKey, + )) as WalletData; + const basePath = getEphemeralWalletBasePath(railgunIndex, chainId); + const pathSuffix = getEphemeralSignerPathSuffix(this.ephemeralSignerProvider, index); + return deriveEphemeralWalletFromPathSuffix(mnemonic, basePath, pathSuffix); + } + + /** + * Get current ephemeral key index + * @returns {Promise} + */ + async getEphemeralKeyIndex(chainId: bigint): Promise { + const dbPath = getEphemeralKeyIndexDBPath( + this.id, + chainId, + getEphemeralSignerDBPathSuffix(this.ephemeralSignerProvider), + ); + try { + const index = await this.db.get(dbPath, 'utf8'); + return parseInt(index as string, 10); + } catch (err) { + return 0; + } + } + + /** + * Set ephemeral key index + * @param {bigint} chainId - Chain ID for the ephemeral key + * @param {number} index - new index + * @returns {Promise} + */ + async setEphemeralKeyIndex(chainId: bigint, index: number): Promise { + const dbPath = getEphemeralKeyIndexDBPath( + this.id, + chainId, + getEphemeralSignerDBPathSuffix(this.ephemeralSignerProvider), + ); + await this.db.put(dbPath, index.toString(), 'utf8'); + } + + /** + * Get current ephemeral address + * @param {string} encryptionKey - encryption key to use with database + * @param {bigint} chainId - Chain ID for the ephemeral key + * @returns {Promise} + */ + async getCurrentEphemeralAddress( + encryptionKey: string, + chainId: bigint, + ): Promise { + const wallet = await this.getCurrentEphemeralWallet(encryptionKey, chainId); + return wallet.address; + } + + /** + * Get current ephemeral wallet + * @param {string} encryptionKey - encryption key to use with database + * @returns {Promise} + */ + async getCurrentEphemeralWallet( + encryptionKey: string, + chainId: bigint, + ): Promise { + if (this.ephemeralWalletOverride) { + return this.ephemeralWalletOverride; + } + + const index = await this.getEphemeralKeyIndex(chainId); + return this.getEphemeralWallet(encryptionKey, chainId, index); + } + + setEphemeralSignerProvider(provider: EphemeralSignerProvider): void { + this.ephemeralSignerProvider = provider; + } + + setEphemeralWalletDerivationStrategy(strategy: EphemeralWalletDerivationStrategy): void { + this.ephemeralSignerProvider.getPathSuffix = strategy; + } + + /** + * Set current ephemeral wallet. This is not typically needed as the wallet can be derived on demand, but can be used to set a specific wallet if desired. + */ + async setCurrentEphemeralWallet(wallet: HDNodeWallet): Promise { + this.ephemeralWalletOverride = wallet; + } + + /** + * Sign EIP-7702 Authorization and Execution Payload + * @param {string} encryptionKey - encryption key to use with database + * @param {string} contractAddress - RelayAdapt7702 contract address + * @param {bigint} chainId - Chain ID + * @param {(TransactionStructV2 | TransactionStructV3)[]} transactions - Railgun transactions + * @param {RelayAdapt7702.ActionDataStruct} actionData - Action Data + * @returns {Promise<{ authorization: Authorization; signature: string }>} + */ + async sign7702Request( + encryptionKey: string, + contractAddress: string, + chainId: bigint, + transactions: (TransactionStructV2 | TransactionStructV3)[], + actionData: RelayAdapt7702.ActionDataStruct, + nonce: number = 0, + ): Promise<{ authorization: Authorization; signature: string }> { + const ephemeralWallet = await this.getCurrentEphemeralWallet(encryptionKey, chainId); + + const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( + ephemeralWallet, + contractAddress, + chainId, + nonce, + ); + + const signature = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralWallet, + transactions, + actionData, + chainId, + ); + + return { authorization, signature }; + } + + /** + * Ratchet ephemeral key index + * @returns {Promise} + */ + async ratchetEphemeralAddress( + chainId: bigint, + ): Promise { + const index = await this.getEphemeralKeyIndex(chainId); + await this.setEphemeralKeyIndex(chainId, index + 1); + } + /** * Load encrypted node from database with encryption key * @param {BytesData} encryptionKey From 7fb5249f4fdec9864c4846c20b70b732847512b4 Mon Sep 17 00:00:00 2001 From: zy0n Date: Thu, 11 Jun 2026 10:41:18 -0400 Subject: [PATCH 2/8] feat: update relay adapt 7702 abi --- src/abi/V2/RelayAdapt7702.json | 10 + ...RelayAdapt7702_Legacy_PreExecuteNonce.json | 864 ++++++++++++++++++ src/abi/abi.ts | 2 + src/abi/typechain/RelayAdapt7702.ts | 61 +- .../factories/RelayAdapt7702__factory.ts | 69 +- 5 files changed, 975 insertions(+), 31 deletions(-) create mode 100644 src/abi/V2/RelayAdapt7702_Legacy_PreExecuteNonce.json diff --git a/src/abi/V2/RelayAdapt7702.json b/src/abi/V2/RelayAdapt7702.json index 3b09f427..46a81c42 100644 --- a/src/abi/V2/RelayAdapt7702.json +++ b/src/abi/V2/RelayAdapt7702.json @@ -331,6 +331,11 @@ "name": "_actionData", "type": "tuple" }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + }, { "internalType": "bytes", "name": "_signature", @@ -570,6 +575,11 @@ "internalType": "struct RelayAdapt7702.ActionData", "name": "_actionData", "type": "tuple" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" } ], "name": "getExecutePayloadHash", diff --git a/src/abi/V2/RelayAdapt7702_Legacy_PreExecuteNonce.json b/src/abi/V2/RelayAdapt7702_Legacy_PreExecuteNonce.json new file mode 100644 index 00000000..3b09f427 --- /dev/null +++ b/src/abi/V2/RelayAdapt7702_Legacy_PreExecuteNonce.json @@ -0,0 +1,864 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_railgun", + "type": "address" + }, + { + "internalType": "address", + "name": "_wBase", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "callIndex", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "revertReason", + "type": "bytes" + } + ], + "name": "CallFailed", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "callIndex", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "revertReason", + "type": "bytes" + } + ], + "name": "CallError", + "type": "event" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "EXECUTE_TYPEHASH", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MULTICALL_TYPEHASH", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "adaptImplementation", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "components": [ + { + "internalType": "uint256", + "name": "x", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "y", + "type": "uint256" + } + ], + "internalType": "struct G1Point", + "name": "a", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256[2]", + "name": "x", + "type": "uint256[2]" + }, + { + "internalType": "uint256[2]", + "name": "y", + "type": "uint256[2]" + } + ], + "internalType": "struct G2Point", + "name": "b", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "x", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "y", + "type": "uint256" + } + ], + "internalType": "struct G1Point", + "name": "c", + "type": "tuple" + } + ], + "internalType": "struct SnarkProof", + "name": "proof", + "type": "tuple" + }, + { + "internalType": "bytes32", + "name": "merkleRoot", + "type": "bytes32" + }, + { + "internalType": "bytes32[]", + "name": "nullifiers", + "type": "bytes32[]" + }, + { + "internalType": "bytes32[]", + "name": "commitments", + "type": "bytes32[]" + }, + { + "components": [ + { + "internalType": "uint16", + "name": "treeNumber", + "type": "uint16" + }, + { + "internalType": "uint72", + "name": "minGasPrice", + "type": "uint72" + }, + { + "internalType": "enum UnshieldType", + "name": "unshield", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "chainID", + "type": "uint64" + }, + { + "internalType": "address", + "name": "adaptContract", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "adaptParams", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "bytes32[4]", + "name": "ciphertext", + "type": "bytes32[4]" + }, + { + "internalType": "bytes32", + "name": "blindedSenderViewingKey", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "blindedReceiverViewingKey", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "annotationData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "memo", + "type": "bytes" + } + ], + "internalType": "struct CommitmentCiphertext[]", + "name": "commitmentCiphertext", + "type": "tuple[]" + } + ], + "internalType": "struct BoundParams", + "name": "boundParams", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "npk", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "enum TokenType", + "name": "tokenType", + "type": "uint8" + }, + { + "internalType": "address", + "name": "tokenAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenSubID", + "type": "uint256" + } + ], + "internalType": "struct TokenData", + "name": "token", + "type": "tuple" + }, + { + "internalType": "uint120", + "name": "value", + "type": "uint120" + } + ], + "internalType": "struct CommitmentPreimage", + "name": "unshieldPreimage", + "type": "tuple" + } + ], + "internalType": "struct Transaction[]", + "name": "_transactions", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "bool", + "name": "requireSuccess", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "minGasLimit", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "internalType": "struct RelayAdapt7702.Call[]", + "name": "calls", + "type": "tuple[]" + } + ], + "internalType": "struct RelayAdapt7702.ActionData", + "name": "_actionData", + "type": "tuple" + }, + { + "internalType": "bytes", + "name": "_signature", + "type": "bytes" + } + ], + "name": "execute", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "components": [ + { + "internalType": "uint256", + "name": "x", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "y", + "type": "uint256" + } + ], + "internalType": "struct G1Point", + "name": "a", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256[2]", + "name": "x", + "type": "uint256[2]" + }, + { + "internalType": "uint256[2]", + "name": "y", + "type": "uint256[2]" + } + ], + "internalType": "struct G2Point", + "name": "b", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "x", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "y", + "type": "uint256" + } + ], + "internalType": "struct G1Point", + "name": "c", + "type": "tuple" + } + ], + "internalType": "struct SnarkProof", + "name": "proof", + "type": "tuple" + }, + { + "internalType": "bytes32", + "name": "merkleRoot", + "type": "bytes32" + }, + { + "internalType": "bytes32[]", + "name": "nullifiers", + "type": "bytes32[]" + }, + { + "internalType": "bytes32[]", + "name": "commitments", + "type": "bytes32[]" + }, + { + "components": [ + { + "internalType": "uint16", + "name": "treeNumber", + "type": "uint16" + }, + { + "internalType": "uint72", + "name": "minGasPrice", + "type": "uint72" + }, + { + "internalType": "enum UnshieldType", + "name": "unshield", + "type": "uint8" + }, + { + "internalType": "uint64", + "name": "chainID", + "type": "uint64" + }, + { + "internalType": "address", + "name": "adaptContract", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "adaptParams", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "bytes32[4]", + "name": "ciphertext", + "type": "bytes32[4]" + }, + { + "internalType": "bytes32", + "name": "blindedSenderViewingKey", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "blindedReceiverViewingKey", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "annotationData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "memo", + "type": "bytes" + } + ], + "internalType": "struct CommitmentCiphertext[]", + "name": "commitmentCiphertext", + "type": "tuple[]" + } + ], + "internalType": "struct BoundParams", + "name": "boundParams", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "npk", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "enum TokenType", + "name": "tokenType", + "type": "uint8" + }, + { + "internalType": "address", + "name": "tokenAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenSubID", + "type": "uint256" + } + ], + "internalType": "struct TokenData", + "name": "token", + "type": "tuple" + }, + { + "internalType": "uint120", + "name": "value", + "type": "uint120" + } + ], + "internalType": "struct CommitmentPreimage", + "name": "unshieldPreimage", + "type": "tuple" + } + ], + "internalType": "struct Transaction[]", + "name": "_transactions", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "bool", + "name": "requireSuccess", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "minGasLimit", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "internalType": "struct RelayAdapt7702.Call[]", + "name": "calls", + "type": "tuple[]" + } + ], + "internalType": "struct RelayAdapt7702.ActionData", + "name": "_actionData", + "type": "tuple" + } + ], + "name": "getExecutePayloadHash", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "_requireSuccess", + "type": "bool" + }, + { + "components": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "internalType": "struct RelayAdapt7702.Call[]", + "name": "_calls", + "type": "tuple[]" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + } + ], + "name": "getMulticallPayloadHash", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "_requireSuccess", + "type": "bool" + }, + { + "components": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "internalType": "struct RelayAdapt7702.Call[]", + "name": "_calls", + "type": "tuple[]" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_signature", + "type": "bytes" + } + ], + "name": "multicall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "nonce", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "railgun", + "outputs": [ + { + "internalType": "contract RailgunSmartWallet", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "internalType": "bytes32", + "name": "npk", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "enum TokenType", + "name": "tokenType", + "type": "uint8" + }, + { + "internalType": "address", + "name": "tokenAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenSubID", + "type": "uint256" + } + ], + "internalType": "struct TokenData", + "name": "token", + "type": "tuple" + }, + { + "internalType": "uint120", + "name": "value", + "type": "uint120" + } + ], + "internalType": "struct CommitmentPreimage", + "name": "preimage", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "bytes32[3]", + "name": "encryptedBundle", + "type": "bytes32[3]" + }, + { + "internalType": "bytes32", + "name": "shieldKey", + "type": "bytes32" + } + ], + "internalType": "struct ShieldCiphertext", + "name": "ciphertext", + "type": "tuple" + } + ], + "internalType": "struct ShieldRequest[]", + "name": "_shieldRequests", + "type": "tuple[]" + } + ], + "name": "shield", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "internalType": "enum TokenType", + "name": "tokenType", + "type": "uint8" + }, + { + "internalType": "address", + "name": "tokenAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenSubID", + "type": "uint256" + } + ], + "internalType": "struct TokenData", + "name": "token", + "type": "tuple" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "internalType": "struct RelayAdapt7702.TokenTransfer[]", + "name": "_transfers", + "type": "tuple[]" + } + ], + "name": "transfer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "name": "unwrapBase", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "wBase", + "outputs": [ + { + "internalType": "contract IWBase", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "name": "wrapBase", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "stateMutability": "payable", + "type": "receive" + } +] diff --git a/src/abi/abi.ts b/src/abi/abi.ts index 55a1afc3..48b2faa3 100644 --- a/src/abi/abi.ts +++ b/src/abi/abi.ts @@ -5,6 +5,7 @@ import ABIRailgunLogic_LegacyEvents from './V1/RailgunLogic_LegacyEvents.json'; import ABIRailgunSmartWallet_Legacy_PreMar23 from './V2/RailgunSmartWallet_Legacy_PreMar23.json'; import ABIRelayAdapt from './V2/RelayAdapt.json'; import ABIRelayAdapt7702 from './V2/RelayAdapt7702.json'; +import ABIRelayAdapt7702_Legacy_PreExecuteNonce from './V2/RelayAdapt7702_Legacy_PreExecuteNonce.json'; import ABIRegistry from './V2/Registry.json'; // V2.1 @@ -21,6 +22,7 @@ export { ABIRailgunSmartWallet, ABIRelayAdapt, ABIRelayAdapt7702, + ABIRelayAdapt7702_Legacy_PreExecuteNonce, ABIRegistry, ABIPoseidonMerkleAccumulator, ABIPoseidonMerkleVerifier, diff --git a/src/abi/typechain/RelayAdapt7702.ts b/src/abi/typechain/RelayAdapt7702.ts index a9ab34df..eb23dd7e 100644 --- a/src/abi/typechain/RelayAdapt7702.ts +++ b/src/abi/typechain/RelayAdapt7702.ts @@ -219,13 +219,13 @@ export declare namespace RelayAdapt7702 { export interface RelayAdapt7702Interface extends Interface { getFunction( nameOrSignature: - | "CALL_TYPEHASH" | "DOMAIN_SEPARATOR" | "EXECUTE_TYPEHASH" | "MULTICALL_TYPEHASH" | "adaptImplementation" | "execute" | "getExecutePayloadHash" + | "getMulticallPayloadHash" | "multicall" | "nonce" | "railgun" @@ -238,10 +238,6 @@ export interface RelayAdapt7702Interface extends Interface { getEvent(nameOrSignatureOrTopic: "CallError"): EventFragment; - encodeFunctionData( - functionFragment: "CALL_TYPEHASH", - values?: undefined - ): string; encodeFunctionData( functionFragment: "DOMAIN_SEPARATOR", values?: undefined @@ -260,11 +256,20 @@ export interface RelayAdapt7702Interface extends Interface { ): string; encodeFunctionData( functionFragment: "execute", - values: [TransactionStruct[], RelayAdapt7702.ActionDataStruct, BytesLike] + values: [ + TransactionStruct[], + RelayAdapt7702.ActionDataStruct, + BigNumberish, + BytesLike + ] ): string; encodeFunctionData( functionFragment: "getExecutePayloadHash", - values: [TransactionStruct[], RelayAdapt7702.ActionDataStruct] + values: [TransactionStruct[], RelayAdapt7702.ActionDataStruct, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "getMulticallPayloadHash", + values: [boolean, RelayAdapt7702.CallStruct[], BigNumberish] ): string; encodeFunctionData( functionFragment: "multicall", @@ -290,10 +295,6 @@ export interface RelayAdapt7702Interface extends Interface { values: [BigNumberish] ): string; - decodeFunctionResult( - functionFragment: "CALL_TYPEHASH", - data: BytesLike - ): Result; decodeFunctionResult( functionFragment: "DOMAIN_SEPARATOR", data: BytesLike @@ -315,6 +316,10 @@ export interface RelayAdapt7702Interface extends Interface { functionFragment: "getExecutePayloadHash", data: BytesLike ): Result; + decodeFunctionResult( + functionFragment: "getMulticallPayloadHash", + data: BytesLike + ): Result; decodeFunctionResult(functionFragment: "multicall", data: BytesLike): Result; decodeFunctionResult(functionFragment: "nonce", data: BytesLike): Result; decodeFunctionResult(functionFragment: "railgun", data: BytesLike): Result; @@ -382,8 +387,6 @@ export interface RelayAdapt7702 extends BaseContract { event?: TCEvent ): Promise; - CALL_TYPEHASH: TypedContractMethod<[], [string], "view">; - DOMAIN_SEPARATOR: TypedContractMethod<[], [string], "view">; EXECUTE_TYPEHASH: TypedContractMethod<[], [string], "view">; @@ -396,6 +399,7 @@ export interface RelayAdapt7702 extends BaseContract { [ _transactions: TransactionStruct[], _actionData: RelayAdapt7702.ActionDataStruct, + _nonce: BigNumberish, _signature: BytesLike ], [void], @@ -405,7 +409,18 @@ export interface RelayAdapt7702 extends BaseContract { getExecutePayloadHash: TypedContractMethod< [ _transactions: TransactionStruct[], - _actionData: RelayAdapt7702.ActionDataStruct + _actionData: RelayAdapt7702.ActionDataStruct, + _nonce: BigNumberish + ], + [string], + "view" + >; + + getMulticallPayloadHash: TypedContractMethod< + [ + _requireSuccess: boolean, + _calls: RelayAdapt7702.CallStruct[], + _nonce: BigNumberish ], [string], "view" @@ -452,9 +467,6 @@ export interface RelayAdapt7702 extends BaseContract { key: string | FunctionFragment ): T; - getFunction( - nameOrSignature: "CALL_TYPEHASH" - ): TypedContractMethod<[], [string], "view">; getFunction( nameOrSignature: "DOMAIN_SEPARATOR" ): TypedContractMethod<[], [string], "view">; @@ -473,6 +485,7 @@ export interface RelayAdapt7702 extends BaseContract { [ _transactions: TransactionStruct[], _actionData: RelayAdapt7702.ActionDataStruct, + _nonce: BigNumberish, _signature: BytesLike ], [void], @@ -483,7 +496,19 @@ export interface RelayAdapt7702 extends BaseContract { ): TypedContractMethod< [ _transactions: TransactionStruct[], - _actionData: RelayAdapt7702.ActionDataStruct + _actionData: RelayAdapt7702.ActionDataStruct, + _nonce: BigNumberish + ], + [string], + "view" + >; + getFunction( + nameOrSignature: "getMulticallPayloadHash" + ): TypedContractMethod< + [ + _requireSuccess: boolean, + _calls: RelayAdapt7702.CallStruct[], + _nonce: BigNumberish ], [string], "view" diff --git a/src/abi/typechain/factories/RelayAdapt7702__factory.ts b/src/abi/typechain/factories/RelayAdapt7702__factory.ts index ccf8dd23..3fa0aeec 100644 --- a/src/abi/typechain/factories/RelayAdapt7702__factory.ts +++ b/src/abi/typechain/factories/RelayAdapt7702__factory.ts @@ -60,19 +60,6 @@ const _abi = [ name: "CallError", type: "event", }, - { - inputs: [], - name: "CALL_TYPEHASH", - outputs: [ - { - internalType: "bytes32", - name: "", - type: "bytes32", - }, - ], - stateMutability: "view", - type: "function", - }, { inputs: [], name: "DOMAIN_SEPARATOR", @@ -354,6 +341,11 @@ const _abi = [ name: "_actionData", type: "tuple", }, + { + internalType: "uint256", + name: "_nonce", + type: "uint256", + }, { internalType: "bytes", name: "_signature", @@ -594,6 +586,11 @@ const _abi = [ name: "_actionData", type: "tuple", }, + { + internalType: "uint256", + name: "_nonce", + type: "uint256", + }, ], name: "getExecutePayloadHash", outputs: [ @@ -606,6 +603,52 @@ const _abi = [ stateMutability: "pure", type: "function", }, + { + inputs: [ + { + internalType: "bool", + name: "_requireSuccess", + type: "bool", + }, + { + components: [ + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + internalType: "struct RelayAdapt7702.Call[]", + name: "_calls", + type: "tuple[]", + }, + { + internalType: "uint256", + name: "_nonce", + type: "uint256", + }, + ], + name: "getMulticallPayloadHash", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "pure", + type: "function", + }, { inputs: [ { From 5cc28065d2d9df73afa9a6f705a3b01657b49518 Mon Sep 17 00:00:00 2001 From: zy0n Date: Thu, 11 Jun 2026 11:03:54 -0400 Subject: [PATCH 3/8] feat: select relay adapt 7702 execute abi --- .../relay-adapt/V2/relay-adapt-7702.ts | 53 ++++++++++-- .../relay-adapt/relay-adapt-7702-helper.ts | 35 +++++++- src/models/relay-adapt-types.ts | 3 + src/railgun-engine.ts | 8 +- .../relay-adapt-7702-signature.test.ts | 53 +++++++++++- src/transaction/relay-adapt-7702-signature.ts | 44 +++++++++- src/validation/extract-transaction-data-v2.ts | 80 +++++++++++++++---- src/validation/relay-adapt-7702-validator.ts | 10 ++- src/wallet/railgun-wallet.ts | 9 +++ 9 files changed, 261 insertions(+), 34 deletions(-) diff --git a/src/contracts/relay-adapt/V2/relay-adapt-7702.ts b/src/contracts/relay-adapt/V2/relay-adapt-7702.ts index 82de098b..15439850 100644 --- a/src/contracts/relay-adapt/V2/relay-adapt-7702.ts +++ b/src/contracts/relay-adapt/V2/relay-adapt-7702.ts @@ -10,7 +10,7 @@ import { toUtf8String, Authorization, } from 'ethers'; -import { ABIRelayAdapt, ABIRelayAdapt7702 } from '../../../abi/abi'; +import { ABIRelayAdapt, ABIRelayAdapt7702, ABIRelayAdapt7702_Legacy_PreExecuteNonce } from '../../../abi/abi'; import { TransactionReceiptLog } from '../../../models/formatted-types'; import { getTokenDataERC20 } from '../../../note/note-util'; import { ZERO_ADDRESS } from '../../../utils/constants'; @@ -22,6 +22,10 @@ import { RelayAdapt7702 } from '../../../abi/typechain/RelayAdapt7702'; import { PayableOverrides } from '../../../abi/typechain/common'; import { TransactionStructV2 } from '../../../models/transaction-types'; import { MINIMUM_RELAY_ADAPT_CROSS_CONTRACT_CALLS_GAS_LIMIT_V2 } from '../constants'; +import { + DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, + RelayAdapt7702ExecutionType, +} from '../../../transaction/relay-adapt-7702-signature'; enum RelayAdaptEvent { CallError = 'CallError', @@ -33,6 +37,12 @@ export const RETURN_DATA_STRING_PREFIX = '0x08c379a0'; export class RelayAdapt7702Contract { private readonly contract: RelayAdapt; + private readonly provider: Provider; + + private readonly relayAdapt7702Interface: Interface; + + private readonly executionType: RelayAdapt7702ExecutionType; + readonly address: string; /** @@ -40,11 +50,21 @@ export class RelayAdapt7702Contract { * @param relayAdaptV2ContractAddress - address of Railgun relay adapt contract * @param provider - Network provider */ - constructor(relayAdaptV2ContractAddress: string, provider: Provider) { + constructor( + relayAdaptV2ContractAddress: string, + provider: Provider, + executionType: RelayAdapt7702ExecutionType = DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, + ) { this.address = relayAdaptV2ContractAddress; + this.provider = provider; + this.executionType = executionType; + const relayAdapt7702ABI = executionType === RelayAdapt7702ExecutionType.LegacyPreExecuteNonce + ? ABIRelayAdapt7702_Legacy_PreExecuteNonce + : ABIRelayAdapt7702; + this.relayAdapt7702Interface = new Interface(relayAdapt7702ABI); this.contract = new Contract( relayAdaptV2ContractAddress, - ABIRelayAdapt7702, + relayAdapt7702ABI, provider, ) as unknown as RelayAdapt; } @@ -386,14 +406,27 @@ export class RelayAdapt7702Contract { ); const sig = signature ?? '0x'; + const executeNonce = this.executionType === RelayAdapt7702ExecutionType.ExecuteWithNonce + ? await this.getExecuteNonce(ephemeralAddress) + : undefined; - const populatedTransaction = await (this.contract as any).execute.populateTransaction( + const data = RelayAdapt7702Helper.encodeExecute( + this.relayAdapt7702Interface, transactions, actionData, sig, - overrides, + { + executionType: this.executionType, + executeNonce, + }, ); + const populatedTransaction = { + to: this.address, + data, + ...overrides, + } as ContractTransaction; + if (ephemeralAddress) { populatedTransaction.to = ephemeralAddress; } @@ -405,6 +438,16 @@ export class RelayAdapt7702Contract { return populatedTransaction; } + private async getExecuteNonce(ephemeralAddress?: string): Promise { + const nonceContractAddress = ephemeralAddress ?? this.address; + const nonceContract = new Contract( + nonceContractAddress, + ABIRelayAdapt7702, + this.provider, + ) as unknown as RelayAdapt7702; + return nonceContract.nonce(); + } + private static getCallErrorTopic() { const iface = new Interface(ABIRelayAdapt); return iface.encodeFilterTopics(RelayAdaptEvent.CallError, [])[0]; diff --git a/src/contracts/relay-adapt/relay-adapt-7702-helper.ts b/src/contracts/relay-adapt/relay-adapt-7702-helper.ts index ee4eb510..603f658b 100644 --- a/src/contracts/relay-adapt/relay-adapt-7702-helper.ts +++ b/src/contracts/relay-adapt/relay-adapt-7702-helper.ts @@ -15,6 +15,8 @@ import { RelayAdapt7702 } from '../../abi/typechain/RelayAdapt7702'; import { TransactionStructV2, TransactionStructV3 } from '../../models/transaction-types'; import { signEIP7702Authorization as signEIP7702AuthorizationCore } from '../../transaction/eip7702'; import { + RelayAdapt7702ExecutionDetails, + RelayAdapt7702ExecutionType, getExecutePayloadHash, signExecutionAuthorization as signExecutionAuthorizationCore, ZERO_7702_ADAPT_PARAMS, @@ -43,15 +45,44 @@ class RelayAdapt7702Helper { transactions: (TransactionStructV2 | TransactionStructV3)[], actionData: RelayAdapt7702.ActionDataStruct, chainId: bigint, + executionDetails?: RelayAdapt7702ExecutionDetails, ): Promise { - return signExecutionAuthorizationCore(signer, transactions, actionData, Number(chainId)); + return signExecutionAuthorizationCore(signer, transactions, actionData, Number(chainId), executionDetails); } static getExecutePayloadHash( transactions: (TransactionStructV2 | TransactionStructV3)[], actionData: RelayAdapt7702.ActionDataStruct, + executionDetails?: RelayAdapt7702ExecutionDetails, ): string { - return getExecutePayloadHash(transactions, actionData); + return getExecutePayloadHash(transactions, actionData, executionDetails); + } + + static encodeExecute( + relayAdapt7702Interface: Interface, + transactions: TransactionStructV2[], + actionData: RelayAdapt7702.ActionDataStruct, + signature: string, + executionDetails: RelayAdapt7702ExecutionDetails, + ): string { + if (executionDetails.executionType === RelayAdapt7702ExecutionType.LegacyPreExecuteNonce) { + return relayAdapt7702Interface.encodeFunctionData('execute', [ + transactions, + actionData, + signature, + ]); + } + + if (executionDetails.executeNonce == null) { + throw new Error('RelayAdapt7702 execute nonce required for nonce-aware execute.'); + } + + return relayAdapt7702Interface.encodeFunctionData('execute', [ + transactions, + actionData, + executionDetails.executeNonce, + signature, + ]); } static getZeroAdaptParams(): string { diff --git a/src/models/relay-adapt-types.ts b/src/models/relay-adapt-types.ts index 23fbb1b2..56e0178d 100644 --- a/src/models/relay-adapt-types.ts +++ b/src/models/relay-adapt-types.ts @@ -1,6 +1,7 @@ import { Authorization } from 'ethers'; import { TransactionStructV2 } from './transaction-types'; import { RelayAdapt7702 } from '../abi/typechain/RelayAdapt7702'; +import { RelayAdapt7702ExecutionType } from '../transaction/relay-adapt-7702-signature'; export interface RelayAdapt7702Request { transactions: TransactionStructV2[]; @@ -8,4 +9,6 @@ export interface RelayAdapt7702Request { authorization: Authorization; executionSignature: string; ephemeralAddress: string; + executionType: RelayAdapt7702ExecutionType; + executeNonce?: bigint; } diff --git a/src/railgun-engine.ts b/src/railgun-engine.ts index a495ef3f..3cc718a9 100644 --- a/src/railgun-engine.ts +++ b/src/railgun-engine.ts @@ -4,6 +4,7 @@ import { FallbackProvider } from 'ethers'; import { RailgunSmartWalletContract } from './contracts/railgun-smart-wallet/V2/railgun-smart-wallet'; import { RelayAdaptV2Contract } from './contracts/relay-adapt/V2/relay-adapt-v2'; import { RelayAdapt7702Contract } from './contracts/relay-adapt/V2/relay-adapt-7702'; +import { RelayAdapt7702ExecutionType } from './transaction/relay-adapt-7702-signature'; import { RegistryContract, REGISTRY_NAME_RELAY_ADAPT_7702, @@ -1528,6 +1529,7 @@ class RailgunEngine extends EventEmitter { supportsV3: boolean, relayAdapt7702ContractAddress?: string, railgunRegistryContractAddress?: string, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, ) { EngineDebug.log(`loadNetwork: ${chain.type}:${chain.id}`); @@ -1619,7 +1621,11 @@ class RailgunEngine extends EventEmitter { ContractStore.relayAdapt7702Contracts.set( null, chain, - new RelayAdapt7702Contract(relayAdapt7702ContractAddress, defaultProvider), + new RelayAdapt7702Contract( + relayAdapt7702ContractAddress, + defaultProvider, + relayAdapt7702ExecutionType, + ), ); } diff --git a/src/transaction/__tests__/relay-adapt-7702-signature.test.ts b/src/transaction/__tests__/relay-adapt-7702-signature.test.ts index c1d271f9..10d4525b 100644 --- a/src/transaction/__tests__/relay-adapt-7702-signature.test.ts +++ b/src/transaction/__tests__/relay-adapt-7702-signature.test.ts @@ -1,6 +1,12 @@ import { expect } from 'chai'; -import { Wallet, verifyTypedData } from 'ethers'; -import { getExecutePayloadHash, signExecutionAuthorization } from '../relay-adapt-7702-signature'; +import { AbiCoder, Wallet, keccak256, verifyTypedData } from 'ethers'; +import { + ACTION_DATA_STRUCT_ABI, + RelayAdapt7702ExecutionType, + TRANSACTION_STRUCT_ABI, + getExecutePayloadHash, + signExecutionAuthorization, +} from '../relay-adapt-7702-signature'; import { TransactionStructV2 } from '../../models/transaction-types'; import { RelayAdapt7702 } from '../../abi/typechain/RelayAdapt7702'; import { TXIDVersion } from '../../models/poi-types'; @@ -46,7 +52,17 @@ describe('RelayAdapt7702 Execution Signature', () => { calls: [], }; - const signature = await signExecutionAuthorization(signer, [mockTransaction], mockActionData, chainId); + const executionDetails = { + executionType: RelayAdapt7702ExecutionType.ExecuteWithNonce, + executeNonce: 2n, + }; + const signature = await signExecutionAuthorization( + signer, + [mockTransaction], + mockActionData, + chainId, + executionDetails, + ); const recovered = verifyTypedData( { @@ -59,11 +75,40 @@ describe('RelayAdapt7702 Execution Signature', () => { Execute: [{ name: 'payloadHash', type: 'bytes32' }], }, { - payloadHash: getExecutePayloadHash([mockTransaction], mockActionData), + payloadHash: getExecutePayloadHash([mockTransaction], mockActionData, executionDetails), }, signature, ); expect(recovered).to.equal(signer.address); }); + + it('should encode current and legacy payload hashes explicitly', () => { + const mockActionData: RelayAdapt7702.ActionDataStruct = { + requireSuccess: true, + minGasLimit: 100000n, + calls: [], + }; + const transactions: TransactionStructV2[] = []; + const abiCoder = AbiCoder.defaultAbiCoder(); + const executeNonce = 2n; + + const legacyHash = getExecutePayloadHash(transactions, mockActionData, { + executionType: RelayAdapt7702ExecutionType.LegacyPreExecuteNonce, + }); + const currentHash = getExecutePayloadHash(transactions, mockActionData, { + executionType: RelayAdapt7702ExecutionType.ExecuteWithNonce, + executeNonce, + }); + + expect(legacyHash).to.equal(keccak256(abiCoder.encode( + [`${TRANSACTION_STRUCT_ABI}[]`, ACTION_DATA_STRUCT_ABI], + [transactions, mockActionData], + ))); + expect(currentHash).to.equal(keccak256(abiCoder.encode( + [`${TRANSACTION_STRUCT_ABI}[]`, ACTION_DATA_STRUCT_ABI, 'uint256'], + [transactions, mockActionData, executeNonce], + ))); + expect(currentHash).to.not.equal(legacyHash); + }); }); diff --git a/src/transaction/relay-adapt-7702-signature.ts b/src/transaction/relay-adapt-7702-signature.ts index 5c8e342f..d4def006 100644 --- a/src/transaction/relay-adapt-7702-signature.ts +++ b/src/transaction/relay-adapt-7702-signature.ts @@ -3,6 +3,19 @@ import { TransactionStructV2, TransactionStructV3 } from '../models/transaction- import { RelayAdapt7702__factory } from '../abi/typechain/factories/RelayAdapt7702__factory'; import { RelayAdapt7702 } from '../abi/typechain/RelayAdapt7702'; +export enum RelayAdapt7702ExecutionType { + ExecuteWithNonce = 'ExecuteWithNonce', + LegacyPreExecuteNonce = 'LegacyPreExecuteNonce', +} + +export type RelayAdapt7702ExecutionDetails = { + executionType: RelayAdapt7702ExecutionType; + executeNonce?: bigint; +}; + +export const DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE = + RelayAdapt7702ExecutionType.ExecuteWithNonce; + const iface = RelayAdapt7702__factory.createInterface(); const executeFunc = iface.getFunction('execute'); if (!executeFunc) { @@ -28,11 +41,30 @@ export const ZERO_7702_ADAPT_PARAMS = '0x000000000000000000000000000000000000000 export const getExecutePayloadHash = ( transactions: (TransactionStructV2 | TransactionStructV3)[], actionData: RelayAdapt7702.ActionDataStruct, + executionDetails: RelayAdapt7702ExecutionDetails = { + executionType: DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, + executeNonce: 0n, + }, ): string => { const abiCoder = AbiCoder.defaultAbiCoder(); + const { executionType, executeNonce } = executionDetails; + + if (executionType === RelayAdapt7702ExecutionType.LegacyPreExecuteNonce) { + const encodedLegacy = abiCoder.encode( + [`${TRANSACTION_STRUCT_ABI}[]`, ACTION_DATA_STRUCT_ABI], + [transactions, actionData], + ); + + return keccak256(encodedLegacy); + } + + if (executeNonce == null) { + throw new Error('RelayAdapt7702 execute nonce required for nonce-aware execute.'); + } + const encoded = abiCoder.encode( - [`${TRANSACTION_STRUCT_ABI}[]`, ACTION_DATA_STRUCT_ABI], - [transactions, actionData], + [`${TRANSACTION_STRUCT_ABI}[]`, ACTION_DATA_STRUCT_ABI, 'uint256'], + [transactions, actionData, executeNonce], ); return keccak256(encoded); @@ -52,7 +84,11 @@ export const signExecutionAuthorization = async ( signer: HDNodeWallet | Wallet, transactions: (TransactionStructV2 | TransactionStructV3)[], actionData: RelayAdapt7702.ActionDataStruct, - chainId: number + chainId: number, + executionDetails: RelayAdapt7702ExecutionDetails = { + executionType: DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, + executeNonce: 0n, + }, ): Promise => { const domain = { name: 'RelayAdapt7702', @@ -66,6 +102,6 @@ export const signExecutionAuthorization = async ( }; return signer.signTypedData(domain, types, { - payloadHash: getExecutePayloadHash(transactions, actionData), + payloadHash: getExecutePayloadHash(transactions, actionData, executionDetails), }); }; diff --git a/src/validation/extract-transaction-data-v2.ts b/src/validation/extract-transaction-data-v2.ts index 1a04ce52..1ddb84f0 100644 --- a/src/validation/extract-transaction-data-v2.ts +++ b/src/validation/extract-transaction-data-v2.ts @@ -1,5 +1,10 @@ import { Contract, ContractTransaction } from 'ethers'; -import { ABIRailgunSmartWallet, ABIRelayAdapt, ABIRelayAdapt7702 } from '../abi/abi'; +import { + ABIRailgunSmartWallet, + ABIRelayAdapt, + ABIRelayAdapt7702, + ABIRelayAdapt7702_Legacy_PreExecuteNonce, +} from '../abi/abi'; import { Chain } from '../models/engine-types'; import { TransactionStructOutput } from '../abi/typechain/RailgunSmartWallet'; import { AddressData } from '../key-derivation'; @@ -16,6 +21,7 @@ import { TransactNote } from '../note/transact-note'; import { getSharedSymmetricKey } from '../utils/keys-utils'; import { TokenDataGetter } from '../token/token-data-getter'; import { TXIDVersion } from '../models/poi-types'; +import { RelayAdapt7702ExecutionType } from '../transaction/relay-adapt-7702-signature'; enum TransactionName { RailgunSmartWallet = 'transact', @@ -23,14 +29,23 @@ enum TransactionName { RelayAdapt7702 = 'execute' } -const getABIForTransaction = (transactionName: TransactionName): Array => { +const getABIsForTransaction = ( + transactionName: TransactionName, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, +): Array> => { switch (transactionName) { case TransactionName.RailgunSmartWallet: - return ABIRailgunSmartWallet; + return [ABIRailgunSmartWallet]; case TransactionName.RelayAdapt: - return ABIRelayAdapt; + return [ABIRelayAdapt]; case TransactionName.RelayAdapt7702: - return ABIRelayAdapt7702; + if (relayAdapt7702ExecutionType === RelayAdapt7702ExecutionType.LegacyPreExecuteNonce) { + return [ABIRelayAdapt7702_Legacy_PreExecuteNonce]; + } + if (relayAdapt7702ExecutionType === RelayAdapt7702ExecutionType.ExecuteWithNonce) { + return [ABIRelayAdapt7702]; + } + return [ABIRelayAdapt7702, ABIRelayAdapt7702_Legacy_PreExecuteNonce]; } throw new Error('Unsupported transactionName'); }; @@ -44,6 +59,7 @@ export const extractFirstNoteERC20AmountMapFromTransactionRequestV2 = ( receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, useRelayAdapt7702: boolean = false, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, ): Promise> => { const transactionName = useRelayAdapt7702 ? TransactionName.RelayAdapt7702 @@ -59,6 +75,7 @@ export const extractFirstNoteERC20AmountMapFromTransactionRequestV2 = ( receivingViewingPrivateKey, receivingRailgunAddressData, tokenDataGetter, + relayAdapt7702ExecutionType, ); }; @@ -71,6 +88,7 @@ export const extractRailgunTransactionDataFromTransactionRequestV2 = ( receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, useRelayAdapt7702: boolean = false, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, ): Promise => { const transactionName = useRelayAdapt7702 ? TransactionName.RelayAdapt7702 @@ -86,16 +104,48 @@ export const extractRailgunTransactionDataFromTransactionRequestV2 = ( receivingViewingPrivateKey, receivingRailgunAddressData, tokenDataGetter, + relayAdapt7702ExecutionType, ); }; +const parseTransactionWithABIs = ( + contractAddress: string, + abis: Array>, + transactionRequest: ContractTransaction, +) => { + let lastError: Optional; + + for (const abi of abis) { + try { + const contract = new Contract(contractAddress, abi); + const parsedTransaction = contract.interface.parseTransaction({ + data: transactionRequest.data ?? '', + value: transactionRequest.value, + }); + if (parsedTransaction) { + return parsedTransaction; + } + } catch (cause) { + if (cause instanceof Error) { + lastError = cause; + } + } + } + + if (lastError) { + throw lastError; + } + throw new Error('No transaction parsable from request'); +}; + const getRailgunTransactionRequestsV2 = ( chain: Chain, transactionRequest: ContractTransaction, transactionName: TransactionName, contractAddress: string, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, ): TransactionStructOutput[] => { - const abi = getABIForTransaction(transactionName); + const abis = getABIsForTransaction(transactionName, relayAdapt7702ExecutionType); if ( !transactionRequest.to || @@ -106,15 +156,11 @@ const getRailgunTransactionRequestsV2 = ( ); } - const contract = new Contract(contractAddress, abi); - - const parsedTransaction = contract.interface.parseTransaction({ - data: transactionRequest.data ?? '', - value: transactionRequest.value, - }); - if (!parsedTransaction) { - throw new Error('No transaction parsable from request'); - } + const parsedTransaction = parseTransactionWithABIs( + contractAddress, + abis, + transactionRequest, + ); if (parsedTransaction.name !== transactionName) { throw new Error( `Contract method ${parsedTransaction.name} invalid: expected ${transactionName}`, @@ -145,6 +191,7 @@ const extractFirstNoteERC20AmountMapV2 = async ( receivingViewingPrivateKey: Uint8Array, receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, ): Promise> => { const erc20PaymentAmounts: Record = {}; @@ -153,6 +200,7 @@ const extractFirstNoteERC20AmountMapV2 = async ( transactionRequest, transactionName, contractAddress, + relayAdapt7702ExecutionType, ); await Promise.all( @@ -207,12 +255,14 @@ const extractRailgunTransactionDataV2 = async ( receivingViewingPrivateKey: Uint8Array, receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, ): Promise => { const railgunTxs: TransactionStructOutput[] = getRailgunTransactionRequestsV2( chain, transactionRequest, transactionName, contractAddress, + relayAdapt7702ExecutionType, ); const extractedRailgunTransactionData: ExtractedRailgunTransactionData = await Promise.all( diff --git a/src/validation/relay-adapt-7702-validator.ts b/src/validation/relay-adapt-7702-validator.ts index eaed8241..8e71a144 100644 --- a/src/validation/relay-adapt-7702-validator.ts +++ b/src/validation/relay-adapt-7702-validator.ts @@ -1,7 +1,10 @@ import { verifyTypedData, getBytes, keccak256, encodeRlp, toBeHex, recoverAddress, Authorization } from 'ethers'; import { TransactionStructV2 } from '../models/transaction-types'; import { RelayAdapt7702 } from '../abi/typechain/RelayAdapt7702'; -import { getExecutePayloadHash } from '../transaction/relay-adapt-7702-signature'; +import { + RelayAdapt7702ExecutionDetails, + getExecutePayloadHash, +} from '../transaction/relay-adapt-7702-signature'; export class RelayAdapt7702Validator { static validateAuthorization( @@ -34,7 +37,8 @@ export class RelayAdapt7702Validator { actionData: RelayAdapt7702.ActionDataStruct, signature: string, chainId: number, - expectedSigner: string + expectedSigner: string, + executionDetails?: RelayAdapt7702ExecutionDetails, ): void { const domain = { name: 'RelayAdapt7702', @@ -48,7 +52,7 @@ export class RelayAdapt7702Validator { }; const recoveredAddress = verifyTypedData(domain, types, { - payloadHash: getExecutePayloadHash(transactions, actionData), + payloadHash: getExecutePayloadHash(transactions, actionData, executionDetails), }, signature); if (recoveredAddress.toLowerCase() !== expectedSigner.toLowerCase()) { diff --git a/src/wallet/railgun-wallet.ts b/src/wallet/railgun-wallet.ts index ea5b317d..a3912477 100644 --- a/src/wallet/railgun-wallet.ts +++ b/src/wallet/railgun-wallet.ts @@ -21,6 +21,10 @@ import { HDNodeWallet, Authorization } from 'ethers'; import { RelayAdapt7702Helper } from '../contracts/relay-adapt/relay-adapt-7702-helper'; import { TransactionStructV2, TransactionStructV3 } from '../models/transaction-types'; import { RelayAdapt7702 } from '../abi/typechain/RelayAdapt7702'; +import { + DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, + RelayAdapt7702ExecutionDetails, +} from '../transaction/relay-adapt-7702-signature'; export type EphemeralWalletDerivationStrategy = ( index: number, @@ -241,6 +245,10 @@ class RailgunWallet extends AbstractWallet { transactions: (TransactionStructV2 | TransactionStructV3)[], actionData: RelayAdapt7702.ActionDataStruct, nonce: number = 0, + executionDetails: RelayAdapt7702ExecutionDetails = { + executionType: DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, + executeNonce: 0n, + }, ): Promise<{ authorization: Authorization; signature: string }> { const ephemeralWallet = await this.getCurrentEphemeralWallet(encryptionKey, chainId); @@ -256,6 +264,7 @@ class RailgunWallet extends AbstractWallet { transactions, actionData, chainId, + executionDetails, ); return { authorization, signature }; From e52f5bd9cfaf165db4735c14f17859f24673b377 Mon Sep 17 00:00:00 2001 From: zy0n Date: Mon, 15 Jun 2026 19:36:45 -0400 Subject: [PATCH 4/8] feat(wallet): support hooked 7702 signers --- .../relay-adapt/relay-adapt-7702-helper.ts | 7 +- src/transaction/eip7702.ts | 5 +- src/transaction/index.ts | 1 + src/transaction/relay-adapt-7702-signature.ts | 5 +- src/transaction/relay-adapt-7702-signer.ts | 27 +++++ src/wallet/__tests__/railgun-wallet.test.ts | 99 +++++++++++++++++++ src/wallet/railgun-wallet.ts | 41 +++++++- 7 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 src/transaction/relay-adapt-7702-signer.ts diff --git a/src/contracts/relay-adapt/relay-adapt-7702-helper.ts b/src/contracts/relay-adapt/relay-adapt-7702-helper.ts index 603f658b..40ef097b 100644 --- a/src/contracts/relay-adapt/relay-adapt-7702-helper.ts +++ b/src/contracts/relay-adapt/relay-adapt-7702-helper.ts @@ -1,4 +1,4 @@ -import { ContractTransaction, Wallet, Interface, HDNodeWallet, Authorization } from 'ethers'; +import { ContractTransaction, Interface, Authorization } from 'ethers'; import { ByteUtils } from '../../utils/bytes'; import { ShieldNoteERC20 } from '../../note/erc20/shield-note-erc20'; import { AddressData, decodeAddress } from '../../key-derivation'; @@ -21,6 +21,7 @@ import { signExecutionAuthorization as signExecutionAuthorizationCore, ZERO_7702_ADAPT_PARAMS, } from '../../transaction/relay-adapt-7702-signature'; +import type { RelayAdapt7702HookedSigner } from '../../transaction/relay-adapt-7702-signer'; class RelayAdapt7702Helper { /** @@ -32,7 +33,7 @@ class RelayAdapt7702Helper { * @returns Authorization tuple */ static async signEIP7702Authorization( - signer: Wallet | HDNodeWallet, + signer: RelayAdapt7702HookedSigner, contractAddress: string, chainId: bigint, nonce: number, @@ -41,7 +42,7 @@ class RelayAdapt7702Helper { } static async signExecutionAuthorization( - signer: Wallet | HDNodeWallet, + signer: RelayAdapt7702HookedSigner, transactions: (TransactionStructV2 | TransactionStructV3)[], actionData: RelayAdapt7702.ActionDataStruct, chainId: bigint, diff --git a/src/transaction/eip7702.ts b/src/transaction/eip7702.ts index d1db3336..676b269f 100644 --- a/src/transaction/eip7702.ts +++ b/src/transaction/eip7702.ts @@ -1,4 +1,5 @@ -import { HDNodeWallet, Wallet, Authorization } from 'ethers'; +import { Authorization } from 'ethers'; +import type { RelayAdapt7702HookedSigner } from './relay-adapt-7702-signer'; /** * Signs an EIP-7702 Authorization Tuple using ethers native methods. @@ -9,7 +10,7 @@ import { HDNodeWallet, Wallet, Authorization } from 'ethers'; * @returns Authorization */ export const signEIP7702Authorization = async ( - signer: HDNodeWallet | Wallet, + signer: RelayAdapt7702HookedSigner, contractAddress: string, chainId?: bigint, nonce?: number, diff --git a/src/transaction/index.ts b/src/transaction/index.ts index a80da1ff..dabf500a 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -3,3 +3,4 @@ export * from './transaction-batch'; export * from './railgun-txid'; export * from './eip7702'; export * from './relay-adapt-7702-signature'; +export * from './relay-adapt-7702-signer'; diff --git a/src/transaction/relay-adapt-7702-signature.ts b/src/transaction/relay-adapt-7702-signature.ts index d4def006..d778c5be 100644 --- a/src/transaction/relay-adapt-7702-signature.ts +++ b/src/transaction/relay-adapt-7702-signature.ts @@ -1,7 +1,8 @@ -import { HDNodeWallet, Wallet, AbiCoder, keccak256 } from 'ethers'; +import { AbiCoder, keccak256 } from 'ethers'; import { TransactionStructV2, TransactionStructV3 } from '../models/transaction-types'; import { RelayAdapt7702__factory } from '../abi/typechain/factories/RelayAdapt7702__factory'; import { RelayAdapt7702 } from '../abi/typechain/RelayAdapt7702'; +import type { RelayAdapt7702HookedSigner } from './relay-adapt-7702-signer'; export enum RelayAdapt7702ExecutionType { ExecuteWithNonce = 'ExecuteWithNonce', @@ -81,7 +82,7 @@ export const getExecutePayloadHash = ( * @returns Signature string */ export const signExecutionAuthorization = async ( - signer: HDNodeWallet | Wallet, + signer: RelayAdapt7702HookedSigner, transactions: (TransactionStructV2 | TransactionStructV3)[], actionData: RelayAdapt7702.ActionDataStruct, chainId: number, diff --git a/src/transaction/relay-adapt-7702-signer.ts b/src/transaction/relay-adapt-7702-signer.ts new file mode 100644 index 00000000..372b24ad --- /dev/null +++ b/src/transaction/relay-adapt-7702-signer.ts @@ -0,0 +1,27 @@ +import type { Authorization, AuthorizationRequest, TypedDataDomain, TypedDataField } from 'ethers'; + +export type RelayAdapt7702AuthorizationRequest = AuthorizationRequest; + +export type RelayAdapt7702TypedDataDomain = TypedDataDomain; + +export type RelayAdapt7702TypedDataTypes = Record; + +export type RelayAdapt7702TypedDataValue = Record; + +export interface RelayAdapt7702HookedSigner { + readonly address: string; + + populateAuthorization( + request: RelayAdapt7702AuthorizationRequest, + ): Promise; + + authorize( + request: RelayAdapt7702AuthorizationRequest, + ): Promise; + + signTypedData( + domain: RelayAdapt7702TypedDataDomain, + types: RelayAdapt7702TypedDataTypes, + value: RelayAdapt7702TypedDataValue, + ): Promise; +} \ No newline at end of file diff --git a/src/wallet/__tests__/railgun-wallet.test.ts b/src/wallet/__tests__/railgun-wallet.test.ts index 0a2ec5bc..f71c9836 100644 --- a/src/wallet/__tests__/railgun-wallet.test.ts +++ b/src/wallet/__tests__/railgun-wallet.test.ts @@ -1,6 +1,7 @@ import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { utf8ToBytes } from 'ethereum-cryptography/utils'; +import { Authorization, Signature as EthSignature } from 'ethers'; import memdown from 'memdown'; import { getNoteBlindingKeys, @@ -350,6 +351,104 @@ describe('railgun-wallet', () => { expect(await wallet.getEphemeralKeyIndex(1n)).to.equal(3); }); + it('Should get current ephemeral address from injected signer provider', async () => { + const signerAddress = '0x1111111111111111111111111111111111111111'; + wallet.setEphemeralSignerProvider({ + getPathSuffix: (index) => `${index}'`, + getDBPathSuffix: () => ['ledger'], + getSigner: async (request) => { + expect(request).to.deep.equal({ + railgunWalletID: wallet.id, + railgunAccountIndex: 0, + chainId: 1n, + ephemeralIndex: 4, + }); + return { + address: signerAddress, + populateAuthorization: async (authorizationRequest) => authorizationRequest, + authorize: async (authorizationRequest) => ({ + address: authorizationRequest.address as string, + chainId: authorizationRequest.chainId === undefined + ? 0n + : BigInt(authorizationRequest.chainId.toString()), + nonce: authorizationRequest.nonce === undefined + ? 0n + : BigInt(authorizationRequest.nonce.toString()), + signature: EthSignature.from({ + r: `0x${'11'.repeat(32)}`, + s: `0x${'22'.repeat(32)}`, + yParity: 0, + }), + }), + signTypedData: async () => `0x${'33'.repeat(65)}`, + }; + }, + }); + await wallet.setEphemeralKeyIndex(1n, 4); + + expect(await wallet.getCurrentEphemeralAddress(testEncryptionKey, 1n)).to.equal(signerAddress); + }); + + it('Should sign 7702 request with injected signer provider', async () => { + const contractAddress = '0x2222222222222222222222222222222222222222'; + const authorizationSignature = EthSignature.from({ + r: `0x${'44'.repeat(32)}`, + s: `0x${'55'.repeat(32)}`, + yParity: 1, + }); + const authorization: Authorization = { + address: contractAddress, + chainId: 1n, + nonce: 7n, + signature: authorizationSignature, + }; + const executionSignature = `0x${'66'.repeat(65)}`; + + let typedDataVerifyingContract: Optional; + wallet.setEphemeralSignerProvider({ + getPathSuffix: (index) => `${index}'`, + getDBPathSuffix: () => ['ledger'], + getSigner: async (request) => { + expect(request.ephemeralIndex).to.equal(5); + return { + address: '0x1111111111111111111111111111111111111111', + populateAuthorization: async (authorizationRequest) => authorizationRequest, + authorize: async (authorizationRequest) => { + expect(authorizationRequest.address).to.equal(contractAddress); + expect(authorizationRequest.chainId).to.equal(1n); + expect(authorizationRequest.nonce).to.equal(7n); + return authorization; + }, + signTypedData: async (domain, types, value) => { + typedDataVerifyingContract = domain.verifyingContract ?? undefined; + expect(domain.name).to.equal('RelayAdapt7702'); + expect(types.Execute).to.deep.equal([{ name: 'payloadHash', type: 'bytes32' }]); + expect(typeof value.payloadHash).to.equal('string'); + return executionSignature; + }, + }; + }, + }); + await wallet.setEphemeralKeyIndex(1n, 5); + + const result = await wallet.sign7702Request( + testEncryptionKey, + contractAddress, + 1n, + [], + { + requireSuccess: true, + minGasLimit: 0n, + calls: [], + }, + 7, + ); + + expect(result.authorization).to.equal(authorization); + expect(result.signature).to.equal(executionSignature); + expect(typedDataVerifyingContract).to.equal('0x1111111111111111111111111111111111111111'); + }); + it('Should reject invalid ephemeral key index path suffixes', async () => { wallet.setEphemeralSignerProvider({ getPathSuffix: (index) => `${index}'`, diff --git a/src/wallet/railgun-wallet.ts b/src/wallet/railgun-wallet.ts index a3912477..a0c72892 100644 --- a/src/wallet/railgun-wallet.ts +++ b/src/wallet/railgun-wallet.ts @@ -25,6 +25,7 @@ import { DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, RelayAdapt7702ExecutionDetails, } from '../transaction/relay-adapt-7702-signature'; +import type { RelayAdapt7702HookedSigner } from '../transaction/relay-adapt-7702-signer'; export type EphemeralWalletDerivationStrategy = ( index: number, @@ -33,6 +34,13 @@ export type EphemeralWalletDerivationStrategy = ( // Optional namespace segments appended after the engine-owned chain path. export type EphemeralWalletDBPathStrategy = () => string[]; +export type EphemeralSignerRequest = { + readonly railgunWalletID: string; + readonly railgunAccountIndex: number; + readonly chainId: bigint; + readonly ephemeralIndex: number; +}; + const EPHEMERAL_KEY_INDEX_PROVIDER_NAMESPACE = 'provider'; const getEphemeralKeyIndexBaseDBPath = ( @@ -95,6 +103,7 @@ const getEphemeralSignerDBPathSuffix = ( export interface EphemeralSignerProvider { getPathSuffix: EphemeralWalletDerivationStrategy; getDBPathSuffix: EphemeralWalletDBPathStrategy; + getSigner?: (request: EphemeralSignerRequest) => Promise; } const defaultEphemeralSignerProvider: EphemeralSignerProvider = { @@ -193,8 +202,30 @@ class RailgunWallet extends AbstractWallet { encryptionKey: string, chainId: bigint, ): Promise { - const wallet = await this.getCurrentEphemeralWallet(encryptionKey, chainId); - return wallet.address; + const signer = await this.getCurrentEphemeralSigner(encryptionKey, chainId); + return signer.address; + } + + async getCurrentEphemeralSigner( + encryptionKey: string, + chainId: bigint, + ): Promise { + const ephemeralIndex = await this.getEphemeralKeyIndex(chainId); + if (this.ephemeralSignerProvider.getSigner) { + const { index: railgunAccountIndex } = (await AbstractWallet.read( + this.db, + this.id, + encryptionKey, + )) as WalletData; + return this.ephemeralSignerProvider.getSigner({ + railgunWalletID: this.id, + railgunAccountIndex, + chainId, + ephemeralIndex, + }); + } + + return this.getEphemeralWallet(encryptionKey, chainId, ephemeralIndex); } /** @@ -250,17 +281,17 @@ class RailgunWallet extends AbstractWallet { executeNonce: 0n, }, ): Promise<{ authorization: Authorization; signature: string }> { - const ephemeralWallet = await this.getCurrentEphemeralWallet(encryptionKey, chainId); + const ephemeralSigner = await this.getCurrentEphemeralSigner(encryptionKey, chainId); const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( - ephemeralWallet, + ephemeralSigner, contractAddress, chainId, nonce, ); const signature = await RelayAdapt7702Helper.signExecutionAuthorization( - ephemeralWallet, + ephemeralSigner, transactions, actionData, chainId, From c3085fdb504af00cf35e79a0e2f7a0387cba4b45 Mon Sep 17 00:00:00 2001 From: zy0n Date: Wed, 24 Jun 2026 18:55:19 +0000 Subject: [PATCH 5/8] fix(7702): harden hooked 7702 signer support Address correctness, concurrency, and validation findings on the EIP-7702 hooked-signer feature, with regression tests throughout. Correctness - bind the RelayAdapt7702 execute nonce across signing and population on the cross-contract and base-token paths, so the signed nonce matches the encoded nonce (previously diverged for any nonce > 0) - reject ephemeral derivation on out-of-range (>= 2^31) chain IDs and indices with a clear error instead of an opaque ethers failure - carry chain IDs as bigint through the execution signature to preserve precision above 2^53 Concurrency and key safety - serialize ephemeral key index ratcheting per chain so concurrent callers cannot derive the same ephemeral signer (key/nonce reuse), including the history-recovery scan - scope index ratcheting and history scan to the default derivation provider; custom signer providers manage their own index - stop setEphemeralWalletDerivationStrategy from mutating the shared provider singleton, which leaked a custom strategy across wallets Validation - verify the configured RelayAdapt7702 delegate against the on-chain registry (advisory, timeout-bounded) and assert the expected signer in authorization validation - add an advisory execution-signature check on the spendable-transaction extraction path Cleanups - type the internal contract as RelayAdapt7702, guard a non-numeric stored index, drop a dead getActionData overload and an unnecessary cast, and rename the registry test fixture --- src/__tests__/railgun-engine.test.ts | 2 +- .../relay-adapt-7702-registry.test.ts | 27 ++++ .../relay-adapt/V2/relay-adapt-7702.ts | 26 ++- .../__tests__/relay-adapt-7702-nonce.test.ts | 65 ++++++++ .../__tests__/relay-adapt-7702.test.ts | 12 -- .../relay-adapt/relay-adapt-7702-helper.ts | 31 +--- .../relay-adapt-versioned-smart-contracts.ts | 6 + .../__tests__/ephemeral-key.test.ts | 34 ++++ src/key-derivation/bech32.ts | 2 +- src/key-derivation/ephemeral-key.ts | 22 +++ src/railgun-engine.ts | 58 +++++++ src/test/config-overrides.test.ts | 3 +- src/test/config.test.ts | 2 +- .../relay-adapt-7702-signature.test.ts | 40 +++++ src/transaction/relay-adapt-7702-signature.ts | 2 +- .../extract-transaction-data-v2.test.ts | 47 ++++++ .../relay-adapt-7702-validator.test.ts | 150 +++++++++++++++++- src/validation/extract-transaction-data-v2.ts | 57 +++++++ src/validation/relay-adapt-7702-validator.ts | 17 +- .../__tests__/ephemeral-key-manager.test.ts | 35 ++-- src/wallet/__tests__/railgun-wallet.test.ts | 50 ++++++ src/wallet/ephemeral-key-manager.ts | 18 +-- src/wallet/railgun-wallet.ts | 86 +++++++++- 23 files changed, 703 insertions(+), 89 deletions(-) create mode 100644 src/__tests__/relay-adapt-7702-registry.test.ts create mode 100644 src/contracts/relay-adapt/__tests__/relay-adapt-7702-nonce.test.ts create mode 100644 src/validation/__tests__/extract-transaction-data-v2.test.ts diff --git a/src/__tests__/railgun-engine.test.ts b/src/__tests__/railgun-engine.test.ts index 8aebea8e..bef18663 100644 --- a/src/__tests__/railgun-engine.test.ts +++ b/src/__tests__/railgun-engine.test.ts @@ -322,7 +322,7 @@ describe('railgun-engine', function test() { 0, !isV2Test(), // supportsV3 config.contracts.relayAdapt7702, - config.contracts.adapt7702Deployer, + config.contracts.railgunRegistry, ); const balance = await token.balanceOf(ethersWallet.address); diff --git a/src/__tests__/relay-adapt-7702-registry.test.ts b/src/__tests__/relay-adapt-7702-registry.test.ts new file mode 100644 index 00000000..e0d28771 --- /dev/null +++ b/src/__tests__/relay-adapt-7702-registry.test.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai'; +import { RailgunEngine } from '../railgun-engine'; + +describe('RelayAdapt7702 registry address validation', () => { + const configured = '0x5bf5b11053e734690269C6B9D438F8C9d48F528A'; + + it('matches when the registry reports the same address (case-insensitive)', () => { + expect( + RailgunEngine.relayAdapt7702AddressMatchesRegistry(configured.toLowerCase(), configured), + ).to.equal(true); + }); + + it('treats an absent or zero registry entry as unconfirmed (no block)', () => { + expect(RailgunEngine.relayAdapt7702AddressMatchesRegistry(undefined, configured)).to.equal( + true, + ); + expect( + RailgunEngine.relayAdapt7702AddressMatchesRegistry(`0x${'0'.repeat(40)}`, configured), + ).to.equal(true); + }); + + it('flags a concrete mismatch (a wrong delegate must block)', () => { + expect( + RailgunEngine.relayAdapt7702AddressMatchesRegistry(`0x${'1'.repeat(40)}`, configured), + ).to.equal(false); + }); +}); diff --git a/src/contracts/relay-adapt/V2/relay-adapt-7702.ts b/src/contracts/relay-adapt/V2/relay-adapt-7702.ts index 15439850..0a2d09aa 100644 --- a/src/contracts/relay-adapt/V2/relay-adapt-7702.ts +++ b/src/contracts/relay-adapt/V2/relay-adapt-7702.ts @@ -35,7 +35,7 @@ export const RETURN_DATA_RELAY_ADAPT_STRING_PREFIX = '0x5c0dee5d'; export const RETURN_DATA_STRING_PREFIX = '0x08c379a0'; export class RelayAdapt7702Contract { - private readonly contract: RelayAdapt; + private readonly contract: RelayAdapt7702; private readonly provider: Provider; @@ -66,7 +66,7 @@ export class RelayAdapt7702Contract { relayAdaptV2ContractAddress, relayAdapt7702ABI, provider, - ) as unknown as RelayAdapt; + ) as unknown as RelayAdapt7702; } async populateShieldBaseToken( @@ -75,6 +75,7 @@ export class RelayAdapt7702Contract { signature?: string, random31Bytes?: string, ephemeralAddress?: string, + executeNonce?: bigint, ): Promise { void random31Bytes; @@ -90,6 +91,7 @@ export class RelayAdapt7702Contract { authorization, signature, ephemeralAddress, + executeNonce, ); } @@ -186,6 +188,7 @@ export class RelayAdapt7702Contract { authorization?: Authorization, signature?: string, ephemeralAddress?: string, + executeNonce?: bigint, ): Promise { const orderedCalls: ContractTransaction[] = await this.getOrderedCallsForUnshieldBaseToken( unshieldAddress, @@ -203,6 +206,7 @@ export class RelayAdapt7702Contract { authorization, signature, ephemeralAddress, + executeNonce, ); } @@ -268,6 +272,7 @@ export class RelayAdapt7702Contract { authorization?: Authorization, signature?: string, ephemeralAddress?: string, + executeNonce?: bigint, ): Promise { const orderedCalls: ContractTransaction[] = await this.getOrderedCallsForCrossContractCalls( crossContractCalls, @@ -302,6 +307,7 @@ export class RelayAdapt7702Contract { authorization, signature, ephemeralAddress, + executeNonce, ); // Set default gas limit for cross-contract calls. @@ -366,6 +372,7 @@ export class RelayAdapt7702Contract { authorization?: Authorization, signature?: string, ephemeralAddress?: string, + executeNonce?: bigint, ): Promise { // Always requireSuccess when there is no Broadcaster payment. const requireSuccess = true; @@ -382,6 +389,7 @@ export class RelayAdapt7702Contract { authorization, signature, ephemeralAddress, + executeNonce, ); } @@ -398,16 +406,17 @@ export class RelayAdapt7702Contract { authorization?: Authorization, signature?: string, ephemeralAddress?: string, + executeNonceOverride?: bigint, ): Promise { const actionData: RelayAdapt7702.ActionDataStruct = RelayAdapt7702Helper.getActionData( requireSuccess, calls, minimumGasLimit, ); - + const sig = signature ?? '0x'; const executeNonce = this.executionType === RelayAdapt7702ExecutionType.ExecuteWithNonce - ? await this.getExecuteNonce(ephemeralAddress) + ? executeNonceOverride ?? (await this.getExecuteNonce(ephemeralAddress)) : undefined; const data = RelayAdapt7702Helper.encodeExecute( @@ -438,7 +447,14 @@ export class RelayAdapt7702Contract { return populatedTransaction; } - private async getExecuteNonce(ephemeralAddress?: string): Promise { + /** + * Reads the RelayAdapt7702 execute nonce for the executing account. Callers + * MUST read this once and pass the same value to both the execution signature + * (RelayAdapt7702Helper.signExecutionAuthorization, via executionDetails.executeNonce) + * and populateCrossContractCalls (executeNonce). Otherwise the signed nonce and + * the encoded nonce diverge for any nonce > 0 and on-chain execution reverts. + */ + async getExecuteNonce(ephemeralAddress?: string): Promise { const nonceContractAddress = ephemeralAddress ?? this.address; const nonceContract = new Contract( nonceContractAddress, diff --git a/src/contracts/relay-adapt/__tests__/relay-adapt-7702-nonce.test.ts b/src/contracts/relay-adapt/__tests__/relay-adapt-7702-nonce.test.ts new file mode 100644 index 00000000..1cd3574e --- /dev/null +++ b/src/contracts/relay-adapt/__tests__/relay-adapt-7702-nonce.test.ts @@ -0,0 +1,65 @@ +import { expect } from 'chai'; +import { Provider } from 'ethers'; +import { RelayAdapt7702Contract } from '../V2/relay-adapt-7702'; +import { RelayAdapt7702ExecutionType } from '../../../transaction/relay-adapt-7702-signature'; +import { RelayAdapt7702__factory } from '../../../abi/typechain/factories/RelayAdapt7702__factory'; +import { ByteUtils } from '../../../utils/bytes'; + +const RELAY_ADAPT_7702_ADDRESS = '0x5bf5b11053e734690269C6B9D438F8C9d48F528A'; +const DEAD_ADDRESS = '0x000000000000000000000000000000000000dEaD'; +const DUMMY_SIGNATURE = `0x${'11'.repeat(65)}`; + +// Engine-side proof of the execute-nonce binding fix. The on-chain leg (that the contract +// accepts the signature) still needs the Hardhat suite, but the bug was engine-side: the +// populate path used to re-read the live nonce regardless of what was signed. These tests +// run with a dummy provider, so if getExecuteNonce (a live read) were called they would +// throw — proving the supplied executeNonce is used and the live read is bypassed. +describe('relay-adapt-7702 execute-nonce binding', () => { + const buildContract = () => + new RelayAdapt7702Contract( + RELAY_ADAPT_7702_ADDRESS, + undefined as unknown as Provider, + RelayAdapt7702ExecutionType.ExecuteWithNonce, + ); + + const decodeExecuteNonce = (data: string): bigint => { + const decoded = RelayAdapt7702__factory.createInterface().parseTransaction({ data }); + expect(decoded?.name).to.equal('execute'); + // eslint-disable-next-line no-underscore-dangle + return decoded?.args._nonce as bigint; + }; + + it('encodes the supplied execute nonce on the unshield base-token path (no live read)', async () => { + const executeNonce = 7n; + const populated = await buildContract().populateUnshieldBaseToken( + [], // transactions + DEAD_ADDRESS, // unshieldAddress + ByteUtils.randomHex(31), + false, // useDummyProof + false, // sendWithPublicWallet + undefined, // authorization + DUMMY_SIGNATURE, + DEAD_ADDRESS, // ephemeralAddress + executeNonce, + ); + expect(decodeExecuteNonce(populated.data ?? '')).to.equal(executeNonce); + }); + + it('encodes the supplied execute nonce on the cross-contract-calls path (no live read)', async () => { + const executeNonce = 9n; + const populated = await buildContract().populateCrossContractCalls( + [], // unshieldTransactions + [], // crossContractCalls + [], // relayShieldRequests + ByteUtils.randomHex(31), + false, // isGasEstimate + true, // isBroadcasterTransaction + undefined, // minGasLimit + undefined, // authorization + DUMMY_SIGNATURE, + DEAD_ADDRESS, // ephemeralAddress + executeNonce, + ); + expect(decodeExecuteNonce(populated.data ?? '')).to.equal(executeNonce); + }); +}); diff --git a/src/contracts/relay-adapt/__tests__/relay-adapt-7702.test.ts b/src/contracts/relay-adapt/__tests__/relay-adapt-7702.test.ts index 8b9841c0..1e32a778 100644 --- a/src/contracts/relay-adapt/__tests__/relay-adapt-7702.test.ts +++ b/src/contracts/relay-adapt/__tests__/relay-adapt-7702.test.ts @@ -242,7 +242,6 @@ describe('relay-adapt-7702', function test() { const random31Bytes = ByteUtils.randomHex(31); const actionData = RelayAdapt7702Helper.getActionData( - random31Bytes, true, // requireSuccess orderedCalls, BigInt(0), // minGasLimit @@ -364,7 +363,6 @@ describe('relay-adapt-7702', function test() { ); const actionData = RelayAdapt7702Helper.getActionData( - random, false, // sendWithPublicWallet = false orderedCalls, BigInt(0), @@ -507,7 +505,6 @@ describe('relay-adapt-7702', function test() { ); const actionData = RelayAdapt7702Helper.getActionData( - random, true, // requireSuccess (sendWithPublicWallet=true) orderedCalls, BigInt(0), // minGasLimit @@ -700,7 +697,6 @@ describe('relay-adapt-7702', function test() { const minGasLimitForContract = RelayAdapt7702Contract.getMinimumGasLimitForContract(minGasLimit); const actionDataGasEstimate = RelayAdapt7702Helper.getActionData( - gasEstimateRandom, false, // requireSuccess (isGasEstimate=false, isBroadcasterTransaction=true -> false) orderedCallsGasEstimate, minGasLimitForContract, @@ -793,7 +789,6 @@ describe('relay-adapt-7702', function test() { }); const actionDataFinal = RelayAdapt7702Helper.getActionData( - random, false, // requireSuccess orderedCallsFinal, minGasLimitForContract, @@ -954,7 +949,6 @@ describe('relay-adapt-7702', function test() { const random31Bytes = ByteUtils.randomHex(31); const actionData = RelayAdapt7702Helper.getActionData( - random31Bytes, true, // requireSuccess orderedCalls, BigInt(0), // minGasLimit @@ -1117,7 +1111,6 @@ describe('relay-adapt-7702', function test() { const minGasLimitForContract = RelayAdapt7702Contract.getMinimumGasLimitForContract(minGasLimit); const actionDataGasEstimate = RelayAdapt7702Helper.getActionData( - randomGasEstimate, true, // requireSuccess (isGasEstimate=true -> true) orderedCallsGasEstimate, minGasLimitForContract, @@ -1211,7 +1204,6 @@ describe('relay-adapt-7702', function test() { }); const actionDataFinal = RelayAdapt7702Helper.getActionData( - random, false, // requireSuccess orderedCallsFinal, minGasLimitForContract, @@ -1404,7 +1396,6 @@ describe('relay-adapt-7702', function test() { const minGasLimitForContract = RelayAdapt7702Contract.getMinimumGasLimitForContract(minGasLimit); const actionDataGasEstimate = RelayAdapt7702Helper.getActionData( - gasEstimateRandom, true, // requireSuccess (isGasEstimate=true -> true) orderedCallsGasEstimate, minGasLimitForContract, @@ -1487,7 +1478,6 @@ describe('relay-adapt-7702', function test() { }); const actionDataFinal = RelayAdapt7702Helper.getActionData( - random, false, // requireSuccess orderedCallsFinal, minGasLimitForContract, @@ -1682,7 +1672,6 @@ describe('relay-adapt-7702', function test() { const minGasLimitForContract = RelayAdapt7702Contract.getMinimumGasLimitForContract(minGasLimit); const actionDataGasEstimate = RelayAdapt7702Helper.getActionData( - randomGasEstimate, true, // requireSuccess (isGasEstimate=true -> true) orderedCallsGasEstimate, minGasLimitForContract, @@ -1765,7 +1754,6 @@ describe('relay-adapt-7702', function test() { }); const actionDataFinal = RelayAdapt7702Helper.getActionData( - random, false, // requireSuccess orderedCallsFinal, minGasLimitForContract, diff --git a/src/contracts/relay-adapt/relay-adapt-7702-helper.ts b/src/contracts/relay-adapt/relay-adapt-7702-helper.ts index 40ef097b..1b0ab8a1 100644 --- a/src/contracts/relay-adapt/relay-adapt-7702-helper.ts +++ b/src/contracts/relay-adapt/relay-adapt-7702-helper.ts @@ -48,7 +48,7 @@ class RelayAdapt7702Helper { chainId: bigint, executionDetails?: RelayAdapt7702ExecutionDetails, ): Promise { - return signExecutionAuthorizationCore(signer, transactions, actionData, Number(chainId), executionDetails); + return signExecutionAuthorizationCore(signer, transactions, actionData, chainId, executionDetails); } static getExecutePayloadHash( @@ -159,40 +159,11 @@ class RelayAdapt7702Helper { /** * Format action data field for relay call. */ - static getActionData( - random: string, - requireSuccess: boolean, - calls: ContractTransaction[], - minGasLimit: bigint, - ): RelayAdapt7702.ActionDataStruct; static getActionData( requireSuccess: boolean, calls: ContractTransaction[], minGasLimit: bigint, - ): RelayAdapt7702.ActionDataStruct; - static getActionData( - randomOrRequireSuccess: string | boolean, - callsOrRequireSuccess: ContractTransaction[] | boolean, - callsOrMinGasLimit: ContractTransaction[] | bigint, - maybeMinGasLimit?: bigint, ): RelayAdapt7702.ActionDataStruct { - const requireSuccess = - typeof randomOrRequireSuccess === 'boolean' - ? randomOrRequireSuccess - : (callsOrRequireSuccess as boolean); - const calls = - typeof randomOrRequireSuccess === 'boolean' - ? (callsOrRequireSuccess as ContractTransaction[]) - : (callsOrMinGasLimit as ContractTransaction[]); - const minGasLimit = - typeof randomOrRequireSuccess === 'boolean' - ? (callsOrMinGasLimit as bigint) - : maybeMinGasLimit; - - if (minGasLimit == null) { - throw new Error('RelayAdapt7702Helper.getActionData missing minGasLimit.'); - } - return { requireSuccess, minGasLimit, diff --git a/src/contracts/relay-adapt/relay-adapt-versioned-smart-contracts.ts b/src/contracts/relay-adapt/relay-adapt-versioned-smart-contracts.ts index 3cb26501..7c70d7ac 100644 --- a/src/contracts/relay-adapt/relay-adapt-versioned-smart-contracts.ts +++ b/src/contracts/relay-adapt/relay-adapt-versioned-smart-contracts.ts @@ -33,6 +33,7 @@ export class RelayAdaptVersionedSmartContracts { signature?: string, random31Bytes?: string, ephemeralAddress?: string, + executeNonce?: bigint, ): Promise { if (isRelayAdapt7702) { const contract7702 = ContractStore.relayAdapt7702Contracts.getOrThrow(null, chain); @@ -42,6 +43,7 @@ export class RelayAdaptVersionedSmartContracts { signature, random31Bytes, ephemeralAddress, + executeNonce, ); } return this.getRelayAdaptContract(txidVersion, chain, isRelayAdapt7702).populateShieldBaseToken( @@ -61,6 +63,7 @@ export class RelayAdaptVersionedSmartContracts { authorization?: Authorization, signature?: string, ephemeralAddress?: string, + executeNonce?: bigint, ): Promise { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: { @@ -75,6 +78,7 @@ export class RelayAdaptVersionedSmartContracts { authorization, signature, ephemeralAddress, + executeNonce, ); } const contractV2 = ContractStore.relayAdaptV2Contracts.getOrThrow(null, chain); @@ -112,6 +116,7 @@ export class RelayAdaptVersionedSmartContracts { authorization?: Authorization, signature?: string, ephemeralAddress?: string, + executeNonce?: bigint, ): Promise { switch (txidVersion) { case TXIDVersion.V2_PoseidonMerkle: { @@ -128,6 +133,7 @@ export class RelayAdaptVersionedSmartContracts { authorization, signature, ephemeralAddress, + executeNonce, ); } const contractV2 = ContractStore.relayAdaptV2Contracts.getOrThrow(null, chain); diff --git a/src/key-derivation/__tests__/ephemeral-key.test.ts b/src/key-derivation/__tests__/ephemeral-key.test.ts index 3d5163c7..af6500f5 100644 --- a/src/key-derivation/__tests__/ephemeral-key.test.ts +++ b/src/key-derivation/__tests__/ephemeral-key.test.ts @@ -3,6 +3,7 @@ import { deriveEphemeralWallet, deriveEphemeralWalletFromPathSuffix, getEphemeralWalletBasePath, + getEphemeralWalletPathSuffix, } from '../ephemeral-key'; describe('Ephemeral Key Derivation', () => { @@ -102,4 +103,37 @@ describe('Ephemeral Key Derivation', () => { ), ).to.throw('Ephemeral wallet derivation path suffix must be relative.'); }); + + it('should reject chain IDs at or above the hardened BIP-32 limit', () => { + // 2^31 is the first invalid hardened segment value. + expect(() => getEphemeralWalletBasePath(0, 2147483648n)).to.throw( + 'hardened BIP-32 segment limit', + ); + // 2^31 - 1 is the largest valid hardened chain segment. + expect(() => getEphemeralWalletBasePath(0, 2147483647n)).to.not.throw(); + }); + + it('should reject deriving an ephemeral wallet on a chain ID above the hardened limit', () => { + // e.g. Palm (11297108109) exceeds 2^31 and would otherwise throw opaquely in ethers. + expect(() => deriveEphemeralWallet(mnemonic, railgunIndex, 11297108109n, 0)).to.throw( + 'exceeds the hardened BIP-32 segment limit', + ); + }); + + it('should reject an out-of-range railgun wallet index', () => { + expect(() => getEphemeralWalletBasePath(2147483648, 1n)).to.throw( + 'out of range for a hardened BIP-32 segment', + ); + }); + + it('should reject an out-of-range ephemeral index suffix', () => { + expect(() => getEphemeralWalletPathSuffix(2147483648)).to.throw( + 'out of range for a hardened BIP-32 segment', + ); + // 2^31 - 1 is the largest valid hardened index. + expect(() => getEphemeralWalletPathSuffix(2147483647)).to.not.throw(); + expect(() => deriveEphemeralWallet(mnemonic, railgunIndex, chainId, 2147483648)).to.throw( + 'out of range for a hardened BIP-32 segment', + ); + }); }); diff --git a/src/key-derivation/bech32.ts b/src/key-derivation/bech32.ts index 4a6d1e51..8eb23ec4 100644 --- a/src/key-derivation/bech32.ts +++ b/src/key-derivation/bech32.ts @@ -80,7 +80,7 @@ function decodeAddress(address: string): AddressData { throw new Error('No address to decode'); } - const decoded = bech32m.decode(address as any, ADDRESS_LENGTH_LIMIT); + const decoded = bech32m.decode(address, ADDRESS_LENGTH_LIMIT); if (decoded.prefix !== PREFIX) { throw new Error('Invalid address prefix'); diff --git a/src/key-derivation/ephemeral-key.ts b/src/key-derivation/ephemeral-key.ts index 89aa2614..96cfa758 100644 --- a/src/key-derivation/ephemeral-key.ts +++ b/src/key-derivation/ephemeral-key.ts @@ -2,16 +2,38 @@ import { HDNodeWallet } from 'ethers'; const EPHEMERAL_DERIVATION_PATH_PREFIX = "m/44'/60'/0'/7702"; +// BIP-32 hardened path segments must be below 2^31. +const HARDENED_INDEX_LIMIT = 0x80000000n; + export const getEphemeralWalletBasePath = ( railgunIndex: number, chainId: bigint, ): string => { + if ( + !Number.isInteger(railgunIndex) || + railgunIndex < 0 || + BigInt(railgunIndex) >= HARDENED_INDEX_LIMIT + ) { + throw new Error( + `Cannot derive ephemeral 7702 wallet: railgunIndex ${railgunIndex} is out of range for a hardened BIP-32 segment (0 to 2^31 - 1).`, + ); + } + if (chainId < 0n || chainId >= HARDENED_INDEX_LIMIT) { + throw new Error( + `Cannot derive ephemeral 7702 wallet: chainId ${chainId} exceeds the hardened BIP-32 segment limit (2^31). RelayAdapt7702 ephemeral keys are unsupported on this chain.`, + ); + } return `${EPHEMERAL_DERIVATION_PATH_PREFIX}'/${railgunIndex}'/${chainId.toString(10)}'`; }; export const getEphemeralWalletPathSuffix = ( index: number, ): string => { + if (!Number.isInteger(index) || index < 0 || BigInt(index) >= HARDENED_INDEX_LIMIT) { + throw new Error( + `Cannot derive ephemeral 7702 wallet: index ${index} is out of range for a hardened BIP-32 segment (0 to 2^31 - 1).`, + ); + } return `${index}'`; }; diff --git a/src/railgun-engine.ts b/src/railgun-engine.ts index 3cc718a9..f228ba00 100644 --- a/src/railgun-engine.ts +++ b/src/railgun-engine.ts @@ -1637,6 +1637,10 @@ class RailgunEngine extends EventEmitter { ); } + if (isDefined(relayAdapt7702ContractAddress) && isDefined(railgunRegistryContractAddress)) { + await this.warnIfRelayAdapt7702AddressMismatchesRegistry(chain, relayAdapt7702ContractAddress); + } + if (supportsV3) { ContractStore.poseidonMerkleAccumulatorV3Contracts.set( null, @@ -2372,6 +2376,60 @@ class RailgunEngine extends EventEmitter { } } + /** + * Pure check: returns false only when the registry reports a concrete (defined, + * non-zero) RelayAdapt7702 address that differs from the configured one. An absent + * or zero-address registry entry returns true (cannot confirm or deny). + */ + static relayAdapt7702AddressMatchesRegistry( + registeredAddress: Optional, + configuredAddress: string, + ): boolean { + if (!isDefined(registeredAddress) || /^0x0+$/i.test(registeredAddress)) { + return true; + } + return registeredAddress.toLowerCase() === configuredAddress.toLowerCase(); + } + + /** + * Warn (do not block) if the configured RelayAdapt7702 delegate address disagrees with + * the on-chain registry. Users sign EIP-7702 authorizations delegating to this address, + * so a mismatch is worth surfacing — but the registry can legitimately lag behind freshly + * deployed contracts, and blocking on that would brick otherwise-valid wallets. The engine + * keeps using the configured address; the warning flags the discrepancy for reconciliation. + */ + private async warnIfRelayAdapt7702AddressMismatchesRegistry( + chain: Chain, + configuredAddress: string, + ): Promise { + const registry = ContractStore.railgunRegistryContract.get(null, chain); + if (!registry) { + return; + } + let registeredAddress: Optional; + try { + registeredAddress = await promiseTimeout( + registry.getContractAddress(REGISTRY_NAME_RELAY_ADAPT_7702), + 10_000, + 'Timed out verifying RelayAdapt7702 address against registry', + ); + } catch (cause) { + EngineDebug.error( + new Error('Could not verify RelayAdapt7702 address against registry', { cause }), + true, + ); + return; + } + if (!RailgunEngine.relayAdapt7702AddressMatchesRegistry(registeredAddress, configuredAddress)) { + EngineDebug.error( + new Error( + `RelayAdapt7702 address ${configuredAddress} does not match registry-reported ${registeredAddress} for ${chain.type}:${chain.id}. Using the configured address; update the registry to silence this warning.`, + ), + true, + ); + } + } + // Top-level exports: static encodeAddress = encodeAddress; diff --git a/src/test/config-overrides.test.ts b/src/test/config-overrides.test.ts index cfc56604..dcc3c6ea 100644 --- a/src/test/config-overrides.test.ts +++ b/src/test/config-overrides.test.ts @@ -16,7 +16,6 @@ export const overrides = { "weth9": "0xA7c59f010700930003b33aB25a7a0679C860f29c", "relayAdapt": "0xfaAddC93baf78e89DCf37bA67943E1bE8F37Bb8c", "relayAdapt7702": "0x5bf5b11053e734690269C6B9D438F8C9d48F528A", - "railgunRegistry": "0x3155755b79aA083bd953911C92705B7aA82a18F9", - "adapt7702Deployer": "0x3155755b79aA083bd953911C92705B7aA82a18F9" + "railgunRegistry": "0x3155755b79aA083bd953911C92705B7aA82a18F9" } }; diff --git a/src/test/config.test.ts b/src/test/config.test.ts index 05537350..59dfa615 100644 --- a/src/test/config.test.ts +++ b/src/test/config.test.ts @@ -21,7 +21,7 @@ let config = { weth9: '0xA7c59f010700930003b33aB25a7a0679C860f29c', relayAdapt: '0xfaAddC93baf78e89DCf37bA67943E1bE8F37Bb8c', relayAdapt7702: '0x5bf5b11053e734690269C6B9D438F8C9d48F528A', - adapt7702Deployer: '0x3155755b79aA083bd953911C92705B7aA82a18F9', + railgunRegistry: '0x3155755b79aA083bd953911C92705B7aA82a18F9', // V3 poseidonMerkleAccumulatorV3: '0x2B0d36FACD61B71CC05ab8F3D2355ec3631C0dd5', diff --git a/src/transaction/__tests__/relay-adapt-7702-signature.test.ts b/src/transaction/__tests__/relay-adapt-7702-signature.test.ts index 10d4525b..f7994091 100644 --- a/src/transaction/__tests__/relay-adapt-7702-signature.test.ts +++ b/src/transaction/__tests__/relay-adapt-7702-signature.test.ts @@ -111,4 +111,44 @@ describe('RelayAdapt7702 Execution Signature', () => { ))); expect(currentHash).to.not.equal(legacyHash); }); + + it('preserves chain IDs above 2^53 in the execution signature domain', async () => { + const signer = Wallet.createRandom(); + // 2^53 + 1 is not representable as a JS number, so Number(chainId) would truncate it. + const largeChainId = 9007199254740993n; + const mockActionData: RelayAdapt7702.ActionDataStruct = { + requireSuccess: true, + minGasLimit: 100000n, + calls: [], + }; + const transactions: TransactionStructV2[] = []; + const executionDetails = { + executionType: RelayAdapt7702ExecutionType.ExecuteWithNonce, + executeNonce: 1n, + }; + + const signature = await signExecutionAuthorization( + signer, + transactions, + mockActionData, + largeChainId, + executionDetails, + ); + + // Recover against the FULL bigint chain ID (as the on-chain contract sees block.chainid). + // A truncated Number(chainId) on the signing side would not recover to the signer. + const recovered = verifyTypedData( + { + name: 'RelayAdapt7702', + version: '1', + chainId: largeChainId, + verifyingContract: signer.address, + }, + { Execute: [{ name: 'payloadHash', type: 'bytes32' }] }, + { payloadHash: getExecutePayloadHash(transactions, mockActionData, executionDetails) }, + signature, + ); + + expect(recovered).to.equal(signer.address); + }); }); diff --git a/src/transaction/relay-adapt-7702-signature.ts b/src/transaction/relay-adapt-7702-signature.ts index d778c5be..515575c8 100644 --- a/src/transaction/relay-adapt-7702-signature.ts +++ b/src/transaction/relay-adapt-7702-signature.ts @@ -85,7 +85,7 @@ export const signExecutionAuthorization = async ( signer: RelayAdapt7702HookedSigner, transactions: (TransactionStructV2 | TransactionStructV3)[], actionData: RelayAdapt7702.ActionDataStruct, - chainId: number, + chainId: number | bigint, executionDetails: RelayAdapt7702ExecutionDetails = { executionType: DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, executeNonce: 0n, diff --git a/src/validation/__tests__/extract-transaction-data-v2.test.ts b/src/validation/__tests__/extract-transaction-data-v2.test.ts new file mode 100644 index 00000000..db4914f0 --- /dev/null +++ b/src/validation/__tests__/extract-transaction-data-v2.test.ts @@ -0,0 +1,47 @@ +import { expect } from 'chai'; +import Sinon from 'sinon'; +import { ChainType } from '../../models/engine-types'; +import EngineDebug from '../../debugger/debugger'; +import { validateRelayAdapt7702ExecutionSignatureAdvisory } from '../extract-transaction-data-v2'; + +describe('extract-transaction-data-v2 advisory 7702 execution-signature check', () => { + const chain = { type: ChainType.EVM, id: 1 }; + const expectedSigner = '0x1111111111111111111111111111111111111111'; + const actionData = { requireSuccess: true, minGasLimit: 0n, calls: [] }; + + afterEach(() => { + Sinon.restore(); + }); + + it('does not reject (only logs) when the execution signature is invalid', () => { + const debugStub = Sinon.stub(EngineDebug, 'error'); + const args = { + _transactions: [], + _actionData: actionData, + _nonce: 5n, + _signature: `0x${'11'.repeat(65)}`, // invalid signature + }; + + // Advisory only: must never throw, even though validation fails. + expect(() => + validateRelayAdapt7702ExecutionSignatureAdvisory(chain, expectedSigner, args), + ).to.not.throw(); + // It attempted validation and surfaced the failure as a log, not a rejection. + expect(debugStub.calledOnce).to.equal(true); + }); + + it('skips silently when there is no real signature (gas estimate)', () => { + const debugStub = Sinon.stub(EngineDebug, 'error'); + const args = { + _transactions: [], + _actionData: actionData, + _nonce: 5n, + _signature: '0x', + }; + + expect(() => + validateRelayAdapt7702ExecutionSignatureAdvisory(chain, expectedSigner, args), + ).to.not.throw(); + expect(debugStub.called).to.equal(false); + }); +}); diff --git a/src/validation/__tests__/relay-adapt-7702-validator.test.ts b/src/validation/__tests__/relay-adapt-7702-validator.test.ts index 4eae2d20..ffecd46f 100644 --- a/src/validation/__tests__/relay-adapt-7702-validator.test.ts +++ b/src/validation/__tests__/relay-adapt-7702-validator.test.ts @@ -1,18 +1,160 @@ import { expect } from 'chai'; -import { TRANSACTION_STRUCT_ABI, ACTION_DATA_STRUCT_ABI } from '../../transaction/relay-adapt-7702-signature'; -import { ParamType } from 'ethers'; +import { ParamType, Wallet } from 'ethers'; +import { + TRANSACTION_STRUCT_ABI, + ACTION_DATA_STRUCT_ABI, + RelayAdapt7702ExecutionType, + getExecutePayloadHash, +} from '../../transaction/relay-adapt-7702-signature'; +import { TransactionStructV2 } from '../../models/transaction-types'; +import { RelayAdapt7702 } from '../../abi/typechain/RelayAdapt7702'; +import { RelayAdapt7702Validator } from '../relay-adapt-7702-validator'; describe('RelayAdapt7702Validator', () => { it('should have valid ABI strings derived from TypeChain', () => { expect(TRANSACTION_STRUCT_ABI).to.be.a('string'); - + expect(ACTION_DATA_STRUCT_ABI).to.be.a('string'); // Verify they are valid ParamTypes const transactionType = ParamType.from(TRANSACTION_STRUCT_ABI); expect(transactionType.baseType).to.equal('tuple'); - + const actionDataType = ParamType.from(ACTION_DATA_STRUCT_ABI); expect(actionDataType.baseType).to.equal('tuple'); }); + + describe('validateAuthorization', () => { + const contractAddress = '0x5bf5b11053e734690269C6B9D438F8C9d48F528A'; + + it('recovers the signer and asserts it matches the expected signer', async () => { + const wallet = Wallet.createRandom(); + const authorization = await wallet.authorize({ + address: contractAddress, + nonce: 0n, + chainId: 1n, + }); + const recovered = RelayAdapt7702Validator.validateAuthorization( + authorization, + contractAddress, + 1n, + wallet.address, + ); + expect(recovered.toLowerCase()).to.equal(wallet.address.toLowerCase()); + }); + + it('throws when the recovered signer does not match the expected signer', async () => { + const wallet = Wallet.createRandom(); + const authorization = await wallet.authorize({ + address: contractAddress, + nonce: 0n, + chainId: 1n, + }); + expect(() => + RelayAdapt7702Validator.validateAuthorization( + authorization, + contractAddress, + 1n, + `0x${'9'.repeat(40)}`, + ), + ).to.throw('Authorization signer mismatch'); + }); + + it('throws on chain ID mismatch', async () => { + const wallet = Wallet.createRandom(); + const authorization = await wallet.authorize({ + address: contractAddress, + nonce: 0n, + chainId: 1n, + }); + expect(() => + RelayAdapt7702Validator.validateAuthorization(authorization, contractAddress, 999n), + ).to.throw('Authorization chain ID mismatch'); + }); + + it('throws on contract address mismatch', async () => { + const wallet = Wallet.createRandom(); + const authorization = await wallet.authorize({ + address: contractAddress, + nonce: 0n, + chainId: 1n, + }); + expect(() => + RelayAdapt7702Validator.validateAuthorization(authorization, `0x${'2'.repeat(40)}`, 1n), + ).to.throw('Authorization contract address mismatch'); + }); + }); + + describe('validateExecution', () => { + const executionDetails = { + executionType: RelayAdapt7702ExecutionType.ExecuteWithNonce, + executeNonce: 5n, + }; + const actionData: RelayAdapt7702.ActionDataStruct = { + requireSuccess: true, + minGasLimit: 100000n, + calls: [], + }; + const transactions: TransactionStructV2[] = []; + + const signExecution = ( + wallet: ReturnType, + chainId: bigint, + ): Promise => + wallet.signTypedData( + { name: 'RelayAdapt7702', version: '1', chainId, verifyingContract: wallet.address }, + { Execute: [{ name: 'payloadHash', type: 'bytes32' }] }, + { payloadHash: getExecutePayloadHash(transactions, actionData, executionDetails) }, + ); + + it('accepts a signature that recovers to the expected signer', async () => { + const wallet = Wallet.createRandom(); + const signature = await signExecution(wallet, 1n); + expect(() => + RelayAdapt7702Validator.validateExecution( + transactions, + actionData, + signature, + 1n, + wallet.address, + executionDetails, + ), + ).to.not.throw(); + }); + + it('throws when the signature recovers to a different signer', async () => { + const wallet = Wallet.createRandom(); + const signature = await signExecution(wallet, 1n); + const otherSigner = Wallet.createRandom().address; + expect(() => + RelayAdapt7702Validator.validateExecution( + transactions, + actionData, + signature, + 1n, + otherSigner, + executionDetails, + ), + ).to.throw('Execution signature signer mismatch'); + }); + + it('throws when the signed action data is tampered', async () => { + const wallet = Wallet.createRandom(); + const signature = await signExecution(wallet, 1n); + const tamperedActionData: RelayAdapt7702.ActionDataStruct = { + ...actionData, + requireSuccess: false, + }; + expect(() => + RelayAdapt7702Validator.validateExecution( + transactions, + tamperedActionData, + signature, + 1n, + wallet.address, + executionDetails, + ), + ).to.throw('Execution signature signer mismatch'); + }); + }); }); diff --git a/src/validation/extract-transaction-data-v2.ts b/src/validation/extract-transaction-data-v2.ts index 1ddb84f0..df7bb3ab 100644 --- a/src/validation/extract-transaction-data-v2.ts +++ b/src/validation/extract-transaction-data-v2.ts @@ -22,6 +22,7 @@ import { getSharedSymmetricKey } from '../utils/keys-utils'; import { TokenDataGetter } from '../token/token-data-getter'; import { TXIDVersion } from '../models/poi-types'; import { RelayAdapt7702ExecutionType } from '../transaction/relay-adapt-7702-signature'; +import { RelayAdapt7702Validator } from './relay-adapt-7702-validator'; enum TransactionName { RailgunSmartWallet = 'transact', @@ -138,12 +139,61 @@ const parseTransactionWithABIs = ( throw new Error('No transaction parsable from request'); }; +/** + * Advisory check that the decoded RelayAdapt7702 execute calldata carries an execution + * signature that recovers to the executing (ephemeral) account. For the 7702 path the + * extractor's contractAddress is the ephemeral account (it is asserted to equal the tx + * `to`), which is the EIP-712 verifyingContract, so it is the expected signer. + * + * This is intentionally NOT fail-closed: decoded calldata may not re-encode byte-identically + * (e.g. commitmentCiphertext shape), so a mismatch is a diagnostic signal, not a gate. + * Promote to fail-closed only after verifying re-encode fidelity end-to-end against the + * deployed contract. + */ +export const validateRelayAdapt7702ExecutionSignatureAdvisory = ( + chain: Chain, + expectedSigner: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: any, +): void => { + try { + const signature: string = args['_signature']; + if (!isDefined(signature) || signature.length <= 2) { + // No real signature present (e.g. gas estimate); nothing to validate. + return; + } + const hasNonce = isDefined(args['_nonce']); + RelayAdapt7702Validator.validateExecution( + args['_transactions'], + args['_actionData'], + signature, + BigInt(chain.id), + expectedSigner, + { + executionType: hasNonce + ? RelayAdapt7702ExecutionType.ExecuteWithNonce + : RelayAdapt7702ExecutionType.LegacyPreExecuteNonce, + executeNonce: hasNonce ? BigInt(args['_nonce']) : undefined, + }, + ); + } catch (cause) { + EngineDebug.error( + new Error( + `Advisory: RelayAdapt7702 execution signature did not validate for ${chain.type}:${chain.id}`, + { cause: cause instanceof Error ? cause : undefined }, + ), + true, + ); + } +}; + const getRailgunTransactionRequestsV2 = ( chain: Chain, transactionRequest: ContractTransaction, transactionName: TransactionName, contractAddress: string, relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, + runExecutionSignatureCheck = false, ): TransactionStructOutput[] => { const abis = getABIsForTransaction(transactionName, relayAdapt7702ExecutionType); @@ -170,6 +220,12 @@ const getRailgunTransactionRequestsV2 = ( // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const args = recursivelyDecodeResult(parsedTransaction.args); + if (transactionName === TransactionName.RelayAdapt7702 && runExecutionSignatureCheck) { + // Advisory only: surface (do not reject on) a bad 7702 execution signature. + // Run once (txid-extraction pass) to avoid double-logging on the fee pass. + validateRelayAdapt7702ExecutionSignatureAdvisory(chain, contractAddress, args); + } + // eslint-disable-next-line no-underscore-dangle const railgunTxs: TransactionStructOutput[] = args._transactions; @@ -263,6 +319,7 @@ const extractRailgunTransactionDataV2 = async ( transactionName, contractAddress, relayAdapt7702ExecutionType, + true, // run the advisory execution-signature check on the txid-extraction pass only ); const extractedRailgunTransactionData: ExtractedRailgunTransactionData = await Promise.all( diff --git a/src/validation/relay-adapt-7702-validator.ts b/src/validation/relay-adapt-7702-validator.ts index 8e71a144..0c2d5072 100644 --- a/src/validation/relay-adapt-7702-validator.ts +++ b/src/validation/relay-adapt-7702-validator.ts @@ -1,5 +1,5 @@ import { verifyTypedData, getBytes, keccak256, encodeRlp, toBeHex, recoverAddress, Authorization } from 'ethers'; -import { TransactionStructV2 } from '../models/transaction-types'; +import { TransactionStructV2, TransactionStructV3 } from '../models/transaction-types'; import { RelayAdapt7702 } from '../abi/typechain/RelayAdapt7702'; import { RelayAdapt7702ExecutionDetails, @@ -10,7 +10,8 @@ export class RelayAdapt7702Validator { static validateAuthorization( authorization: Authorization, expectedContractAddress: string, - expectedChainId: number + expectedChainId: number | bigint, + expectedSigner?: string, ): string { if (authorization.address.toLowerCase() !== expectedContractAddress.toLowerCase()) { throw new Error('Authorization contract address mismatch'); @@ -28,15 +29,19 @@ export class RelayAdapt7702Validator { const payload = new Uint8Array([0x05, ...getBytes(rlpEncoded)]); const hash = keccak256(payload); - // Recover address - return recoverAddress(hash, authorization.signature); + // Recover address. ethers rejects non-canonical (high-s, malleable) signatures here. + const recovered = recoverAddress(hash, authorization.signature); + if (expectedSigner != null && recovered.toLowerCase() !== expectedSigner.toLowerCase()) { + throw new Error('Authorization signer mismatch'); + } + return recovered; } static validateExecution( - transactions: TransactionStructV2[], + transactions: (TransactionStructV2 | TransactionStructV3)[], actionData: RelayAdapt7702.ActionDataStruct, signature: string, - chainId: number, + chainId: number | bigint, expectedSigner: string, executionDetails?: RelayAdapt7702ExecutionDetails, ): void { diff --git a/src/wallet/__tests__/ephemeral-key-manager.test.ts b/src/wallet/__tests__/ephemeral-key-manager.test.ts index cb5f98ee..4ab16dcc 100644 --- a/src/wallet/__tests__/ephemeral-key-manager.test.ts +++ b/src/wallet/__tests__/ephemeral-key-manager.test.ts @@ -15,23 +15,27 @@ const MOCK_CHAIN: Chain = { type: ChainType.EVM, id: 1 }; describe('engine ephemeral-key-manager', () => { let getEphemeralWalletStub: SinonStub; - let getEphemeralKeyIndexStub: SinonStub; - let setEphemeralKeyIndexStub: SinonStub; + let incrementEphemeralKeyIndexStub: SinonStub; + let setEphemeralKeyIndexIfGreaterStub: SinonStub; let getTransactionHistoryStub: SinonStub; + let isCanonicalEphemeralProviderStub: SinonStub; let manager: EphemeralKeyManager; beforeEach(() => { const railgunWallet = { getEphemeralWallet: async () => {}, - getEphemeralKeyIndex: async () => {}, - setEphemeralKeyIndex: async () => {}, + incrementEphemeralKeyIndex: async () => {}, + setEphemeralKeyIndexIfGreater: async () => {}, getTransactionHistory: async () => {}, + isCanonicalEphemeralProvider: () => true, } as unknown as RailgunWallet; getEphemeralWalletStub = Sinon.stub(railgunWallet, 'getEphemeralWallet'); - getEphemeralKeyIndexStub = Sinon.stub(railgunWallet, 'getEphemeralKeyIndex'); - setEphemeralKeyIndexStub = Sinon.stub(railgunWallet, 'setEphemeralKeyIndex'); + incrementEphemeralKeyIndexStub = Sinon.stub(railgunWallet, 'incrementEphemeralKeyIndex'); + setEphemeralKeyIndexIfGreaterStub = Sinon.stub(railgunWallet, 'setEphemeralKeyIndexIfGreater'); getTransactionHistoryStub = Sinon.stub(railgunWallet, 'getTransactionHistory'); + isCanonicalEphemeralProviderStub = Sinon.stub(railgunWallet, 'isCanonicalEphemeralProvider'); + isCanonicalEphemeralProviderStub.returns(true); manager = new EphemeralKeyManager(railgunWallet, MOCK_ENCRYPTION_KEY); }); @@ -47,14 +51,12 @@ describe('engine ephemeral-key-manager', () => { it('Should get next wallet and increment index', async () => { const mockWallet = HDNodeWallet.fromPhrase(MOCK_MNEMONIC); - getEphemeralKeyIndexStub.resolves(5); + incrementEphemeralKeyIndexStub.resolves(6); getEphemeralWalletStub.resolves(mockWallet); - setEphemeralKeyIndexStub.resolves(); const wallet = await manager.getNextWallet(BigInt(MOCK_CHAIN.id)); expect(wallet.address).to.equal(mockWallet.address); - expect(getEphemeralKeyIndexStub.calledWith(BigInt(MOCK_CHAIN.id))).to.be.true; - expect(setEphemeralKeyIndexStub.calledWith(BigInt(MOCK_CHAIN.id), 6)).to.be.true; + expect(incrementEphemeralKeyIndexStub.calledWith(BigInt(MOCK_CHAIN.id))).to.be.true; expect(getEphemeralWalletStub.calledWith(MOCK_ENCRYPTION_KEY, BigInt(MOCK_CHAIN.id), 6)).to.be.true; }); @@ -74,12 +76,19 @@ describe('engine ephemeral-key-manager', () => { }, ]); - getEphemeralKeyIndexStub.resolves(0); - setEphemeralKeyIndexStub.resolves(); + setEphemeralKeyIndexIfGreaterStub.resolves(2); const recoveredIndex = await manager.scanHistoryForEphemeralIndex(MOCK_CHAIN, 100); expect(recoveredIndex).to.equal(2); - expect(setEphemeralKeyIndexStub.calledWith(BigInt(MOCK_CHAIN.id), 2)).to.be.true; + expect(setEphemeralKeyIndexIfGreaterStub.calledWith(BigInt(MOCK_CHAIN.id), 2)).to.be.true; + }); + + it('Should reject history scan for a custom (non-canonical) provider', async () => { + isCanonicalEphemeralProviderStub.returns(false); + + await expect(manager.scanHistoryForEphemeralIndex(MOCK_CHAIN, 100)).to.be.rejectedWith( + 'only supported for the default ephemeral provider', + ); }); }); diff --git a/src/wallet/__tests__/railgun-wallet.test.ts b/src/wallet/__tests__/railgun-wallet.test.ts index f71c9836..5d65a497 100644 --- a/src/wallet/__tests__/railgun-wallet.test.ts +++ b/src/wallet/__tests__/railgun-wallet.test.ts @@ -320,6 +320,56 @@ describe('railgun-wallet', () => { expect(await wallet.getEphemeralKeyIndex(10n)).to.equal(5); }); + it('Should reject index ratchet for a custom ephemeral signer provider', async () => { + wallet.setEphemeralWalletDerivationStrategy((index) => `${index}'`); + + await expect(wallet.ratchetEphemeralAddress(1n)).to.be.rejectedWith( + 'only supported for the default ephemeral provider', + ); + }); + + it('Should hand out unique indices under concurrent increment', async () => { + await wallet.setEphemeralKeyIndex(1n, 0); + + const results = await Promise.all( + Array.from({ length: 25 }, () => wallet.incrementEphemeralKeyIndex(1n)), + ); + + // Mutual exclusion: no two concurrent callers may observe the same index. + expect(new Set(results).size).to.equal(25); + expect(Math.max(...results)).to.equal(25); + expect(await wallet.getEphemeralKeyIndex(1n)).to.equal(25); + }); + + it('Should only raise the ephemeral key index, never lower it', async () => { + await wallet.setEphemeralKeyIndex(1n, 5); + + expect(await wallet.setEphemeralKeyIndexIfGreater(1n, 3)).to.equal(5); + expect(await wallet.getEphemeralKeyIndex(1n)).to.equal(5); + + expect(await wallet.setEphemeralKeyIndexIfGreater(1n, 8)).to.equal(8); + expect(await wallet.getEphemeralKeyIndex(1n)).to.equal(8); + }); + + it('Should not leak a custom ephemeral derivation strategy across wallets', async () => { + const otherWallet = await RailgunWallet.fromMnemonic( + db, + testEncryptionKey, + testMnemonic, + 1, // distinct account index → distinct wallet + undefined, + new Prover(testArtifactsGetter), + ); + + wallet.setEphemeralWalletDerivationStrategy((index) => `${index}'/99`); + + // The other wallet must keep the canonical default derivation, unaffected by the + // first wallet's custom strategy. + expect(otherWallet.isCanonicalEphemeralProvider()).to.equal(true); + const otherEphemeral = await otherWallet.getEphemeralWallet(testEncryptionKey, 1n, 2); + expect(otherEphemeral.path).to.equal("m/44'/60'/0'/7702'/1'/1'/2'"); + }); + it('Should keep base path when using injected ephemeral derivation suffix strategy', async () => { wallet.setEphemeralWalletDerivationStrategy((index) => { return `${index}'/99`; diff --git a/src/wallet/ephemeral-key-manager.ts b/src/wallet/ephemeral-key-manager.ts index 6de91a7e..552647ba 100644 --- a/src/wallet/ephemeral-key-manager.ts +++ b/src/wallet/ephemeral-key-manager.ts @@ -25,9 +25,7 @@ export class EphemeralKeyManager { } async getNextWallet(chainId: bigint): Promise { - const currentIndex = await this.railgunWallet.getEphemeralKeyIndex(chainId); - const nextIndex = currentIndex + 1; - await this.railgunWallet.setEphemeralKeyIndex(chainId, nextIndex); + const nextIndex = await this.railgunWallet.incrementEphemeralKeyIndex(chainId); return this.getWallet(chainId, nextIndex); } @@ -35,6 +33,12 @@ export class EphemeralKeyManager { chain: Chain, scanLimit = 100, ): Promise { + if (!this.railgunWallet.isCanonicalEphemeralProvider()) { + throw new Error( + 'scanHistoryForEphemeralIndex is only supported for the default ephemeral provider. ' + + 'A custom ephemeral signer provider must manage its own index.', + ); + } const chainId = BigInt(chain.id); const history = await this.railgunWallet.getTransactionHistory(chain, undefined); @@ -68,12 +72,8 @@ export class EphemeralKeyManager { currentIndex += 1; } + // Raise the stored index atomically so a concurrent ratchet cannot be clobbered. const nextIndex = maxUsedIndex + 1; - const storedIndex = await this.railgunWallet.getEphemeralKeyIndex(chainId); - if (nextIndex > storedIndex) { - await this.railgunWallet.setEphemeralKeyIndex(chainId, nextIndex); - } - - return nextIndex; + return this.railgunWallet.setEphemeralKeyIndexIfGreater(chainId, nextIndex); } } diff --git a/src/wallet/railgun-wallet.ts b/src/wallet/railgun-wallet.ts index a0c72892..17c5e274 100644 --- a/src/wallet/railgun-wallet.ts +++ b/src/wallet/railgun-wallet.ts @@ -114,6 +114,8 @@ const defaultEphemeralSignerProvider: EphemeralSignerProvider = { class RailgunWallet extends AbstractWallet { ephemeralWalletOverride: HDNodeWallet | undefined; private ephemeralSignerProvider: EphemeralSignerProvider = defaultEphemeralSignerProvider; + private ephemeralProviderIsCustom = false; + private ephemeralIndexLocks = new Map>(); /** * Load encrypted spending key Node from database * Spending key should be kept private and only accessed on demand @@ -171,7 +173,8 @@ class RailgunWallet extends AbstractWallet { ); try { const index = await this.db.get(dbPath, 'utf8'); - return parseInt(index as string, 10); + const parsed = parseInt(index as string, 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0; } catch (err) { return 0; } @@ -247,10 +250,27 @@ class RailgunWallet extends AbstractWallet { setEphemeralSignerProvider(provider: EphemeralSignerProvider): void { this.ephemeralSignerProvider = provider; + this.ephemeralProviderIsCustom = true; } setEphemeralWalletDerivationStrategy(strategy: EphemeralWalletDerivationStrategy): void { - this.ephemeralSignerProvider.getPathSuffix = strategy; + // Replace with a fresh object — never mutate the provider in place, which (when the + // field still aliases the shared default singleton) would leak this strategy into + // every other wallet, silently changing their derivation while their own + // ephemeralProviderIsCustom flag stays false. + this.ephemeralSignerProvider = { ...this.ephemeralSignerProvider, getPathSuffix: strategy }; + this.ephemeralProviderIsCustom = true; + } + + /** + * Whether ephemeral derivation uses the engine's default canonical layout + * (m/.../'). A custom signer provider controls its own derivation, so the + * engine cannot reconstruct its addresses from an integer index — index ratcheting + * and history scanning are therefore unsupported for custom providers, which must + * manage their own index via setEphemeralKeyIndex (namespaced by getDBPathSuffix). + */ + isCanonicalEphemeralProvider(): boolean { + return !this.ephemeralProviderIsCustom; } /** @@ -301,6 +321,65 @@ class RailgunWallet extends AbstractWallet { return { authorization, signature }; } + /** + * Serialize a read-modify-write of the ephemeral key index per chain so + * concurrent callers can never observe the same index and derive the same + * ephemeral signer (which would reuse the EIP-7702 key / execute nonce). + */ + private async runExclusiveEphemeralIndex( + chainId: bigint, + fn: () => Promise, + ): Promise { + const previous = this.ephemeralIndexLocks.get(chainId) ?? Promise.resolve(); + const run = previous.then(fn, fn); + this.ephemeralIndexLocks.set( + chainId, + run.then( + () => undefined, + () => undefined, + ), + ); + return run; + } + + /** + * Atomically increment and persist the ephemeral key index for a chain, + * returning the new index. + * @returns {Promise} + */ + async incrementEphemeralKeyIndex(chainId: bigint): Promise { + if (this.ephemeralProviderIsCustom) { + throw new Error( + 'Ephemeral key index ratcheting is only supported for the default ephemeral provider. ' + + 'A custom ephemeral signer provider must manage its own index via setEphemeralKeyIndex.', + ); + } + return this.runExclusiveEphemeralIndex(chainId, async () => { + const index = await this.getEphemeralKeyIndex(chainId); + const nextIndex = index + 1; + await this.setEphemeralKeyIndex(chainId, nextIndex); + return nextIndex; + }); + } + + /** + * Atomically raise the ephemeral key index to candidateIndex when it exceeds the + * current stored index, returning the resulting index. Serialized with + * incrementEphemeralKeyIndex so a concurrent ratchet cannot be clobbered (used by the + * history-recovery scan). + * @returns {Promise} + */ + async setEphemeralKeyIndexIfGreater(chainId: bigint, candidateIndex: number): Promise { + return this.runExclusiveEphemeralIndex(chainId, async () => { + const current = await this.getEphemeralKeyIndex(chainId); + if (candidateIndex > current) { + await this.setEphemeralKeyIndex(chainId, candidateIndex); + return candidateIndex; + } + return current; + }); + } + /** * Ratchet ephemeral key index * @returns {Promise} @@ -308,8 +387,7 @@ class RailgunWallet extends AbstractWallet { async ratchetEphemeralAddress( chainId: bigint, ): Promise { - const index = await this.getEphemeralKeyIndex(chainId); - await this.setEphemeralKeyIndex(chainId, index + 1); + await this.incrementEphemeralKeyIndex(chainId); } /** From 166cde47917bb216a8ab96d14f3cabfadfdce4da Mon Sep 17 00:00:00 2001 From: zy0n Date: Wed, 24 Jun 2026 19:07:58 +0000 Subject: [PATCH 6/8] fix(7702): apply the BIP-39 mnemonic password to ephemeral signer derivation The EIP-7702 ephemeral signer derived its key from the password-less seed (HDNodeWallet.fromPhrase(mnemonic, undefined, path)), while the wallet's id, spending keys, and EOA address all derive from the password-protected seed. For a wallet created with a mnemonic password the ephemeral key was therefore not bound to the password and was derivable from the mnemonic alone. Thread mnemonicPassword through the ephemeral derivation (deriveEphemeralWallet*, getEphemeralWallet, getCurrentEphemeral* helpers, sign7702Request, and EphemeralKeyManager) and assert it reproduces the wallet id at the derivation chokepoint, matching the spending-key path. Custom signer providers (hardware wallets) do not derive from the mnemonic and are unaffected. --- .../__tests__/ephemeral-key.test.ts | 16 +++++++++ src/key-derivation/ephemeral-key.ts | 10 ++++-- src/wallet/__tests__/railgun-wallet.test.ts | 34 +++++++++++++++++++ src/wallet/ephemeral-key-manager.ts | 5 ++- src/wallet/railgun-wallet.ts | 25 +++++++++++--- 5 files changed, 82 insertions(+), 8 deletions(-) diff --git a/src/key-derivation/__tests__/ephemeral-key.test.ts b/src/key-derivation/__tests__/ephemeral-key.test.ts index af6500f5..5943dbaa 100644 --- a/src/key-derivation/__tests__/ephemeral-key.test.ts +++ b/src/key-derivation/__tests__/ephemeral-key.test.ts @@ -136,4 +136,20 @@ describe('Ephemeral Key Derivation', () => { 'out of range for a hardened BIP-32 segment', ); }); + + it('should derive a different wallet when a mnemonic password is used', () => { + const noPassword = deriveEphemeralWallet(mnemonic, railgunIndex, chainId, 0); + const withPassword = deriveEphemeralWallet(mnemonic, railgunIndex, chainId, 0, 'p4ssw0rd'); + + // The password feeds the BIP-39 seed, so the ephemeral key must differ. + expect(withPassword.address).to.not.equal(noPassword.address); + // Deterministic for the same password. + expect(deriveEphemeralWallet(mnemonic, railgunIndex, chainId, 0, 'p4ssw0rd').address).to.equal( + withPassword.address, + ); + // An empty password matches the no-password derivation (BIP-39 default). + expect(deriveEphemeralWallet(mnemonic, railgunIndex, chainId, 0, '').address).to.equal( + noPassword.address, + ); + }); }); diff --git a/src/key-derivation/ephemeral-key.ts b/src/key-derivation/ephemeral-key.ts index 96cfa758..d83f9415 100644 --- a/src/key-derivation/ephemeral-key.ts +++ b/src/key-derivation/ephemeral-key.ts @@ -60,11 +60,15 @@ export const deriveEphemeralWalletFromPathSuffix = ( mnemonic: string, basePath: string, pathSuffix: string, + mnemonicPassword?: string, ): HDNodeWallet => { const normalizedPathSuffix = normalizeEphemeralWalletPathSuffix(pathSuffix); const normalizedBasePath = basePath.replace(/\/+$/g, ''); const path = `${normalizedBasePath}/${normalizedPathSuffix}`; - return HDNodeWallet.fromPhrase(mnemonic, undefined, path); + // The BIP-39 mnemonic password (if any) must feed the seed, exactly as the spending-key + // and EOA-address derivations do; otherwise the ephemeral key would be derivable from the + // mnemonic alone and would not match the password-protected wallet. + return HDNodeWallet.fromPhrase(mnemonic, mnemonicPassword, path); }; /** @@ -74,6 +78,7 @@ export const deriveEphemeralWalletFromPathSuffix = ( * @param railgunIndex - Base RAILGUN wallet derivation index * @param chainId - Chain ID for the ephemeral key * @param index - Index for the ephemeral key (nonce) + * @param mnemonicPassword - BIP-39 mnemonic password, if the wallet uses one * @returns HDNodeWallet */ export const deriveEphemeralWallet = ( @@ -81,8 +86,9 @@ export const deriveEphemeralWallet = ( railgunIndex: number, chainId: bigint, index: number, + mnemonicPassword?: string, ): HDNodeWallet => { const basePath = getEphemeralWalletBasePath(railgunIndex, chainId); const pathSuffix = getEphemeralWalletPathSuffix(index); - return deriveEphemeralWalletFromPathSuffix(mnemonic, basePath, pathSuffix); + return deriveEphemeralWalletFromPathSuffix(mnemonic, basePath, pathSuffix, mnemonicPassword); }; diff --git a/src/wallet/__tests__/railgun-wallet.test.ts b/src/wallet/__tests__/railgun-wallet.test.ts index 5d65a497..0868570a 100644 --- a/src/wallet/__tests__/railgun-wallet.test.ts +++ b/src/wallet/__tests__/railgun-wallet.test.ts @@ -370,6 +370,40 @@ describe('railgun-wallet', () => { expect(otherEphemeral.path).to.equal("m/44'/60'/0'/7702'/1'/1'/2'"); }); + it('Should require the BIP-39 mnemonic password for ephemeral derivation', async () => { + const passwordWallet = await RailgunWallet.fromMnemonicWithPassword( + db, + testEncryptionKey, + testMnemonic, + 'correct-horse', + 0, + undefined, // creationBlockNumbers + new Prover(testArtifactsGetter), + ); + + // Correct password derives an ephemeral wallet. + const ephemeral = await passwordWallet.getEphemeralWallet( + testEncryptionKey, + 1n, + 0, + 'correct-horse', + ); + expect(ephemeral.address).to.be.a('string'); + + // A wrong or missing password fails loudly instead of deriving an unrelated key. + await expect( + passwordWallet.getEphemeralWallet(testEncryptionKey, 1n, 0, 'wrong'), + ).to.be.rejectedWith('Incorrect mnemonic password for wallet.'); + await expect( + passwordWallet.getEphemeralWallet(testEncryptionKey, 1n, 0), + ).to.be.rejectedWith('Incorrect mnemonic password for wallet.'); + + // The ephemeral key is bound to the password: it differs from the same mnemonic with + // no password (the beforeEach `wallet`). + const noPasswordEphemeral = await wallet.getEphemeralWallet(testEncryptionKey, 1n, 0); + expect(ephemeral.address).to.not.equal(noPasswordEphemeral.address); + }); + it('Should keep base path when using injected ephemeral derivation suffix strategy', async () => { wallet.setEphemeralWalletDerivationStrategy((index) => { return `${index}'/99`; diff --git a/src/wallet/ephemeral-key-manager.ts b/src/wallet/ephemeral-key-manager.ts index 552647ba..1f5f6902 100644 --- a/src/wallet/ephemeral-key-manager.ts +++ b/src/wallet/ephemeral-key-manager.ts @@ -5,10 +5,12 @@ import { RailgunWallet } from './railgun-wallet'; export class EphemeralKeyManager { private railgunWallet: RailgunWallet; private encryptionKey: string; + private mnemonicPassword?: string; - constructor(railgunWallet: RailgunWallet, encryptionKey: string) { + constructor(railgunWallet: RailgunWallet, encryptionKey: string, mnemonicPassword?: string) { this.railgunWallet = railgunWallet; this.encryptionKey = encryptionKey; + this.mnemonicPassword = mnemonicPassword; } async getWallet(chainId: bigint, index: number): Promise { @@ -16,6 +18,7 @@ export class EphemeralKeyManager { this.encryptionKey, chainId, index, + this.mnemonicPassword, ); } diff --git a/src/wallet/railgun-wallet.ts b/src/wallet/railgun-wallet.ts index 17c5e274..b60f4c2b 100644 --- a/src/wallet/railgun-wallet.ts +++ b/src/wallet/railgun-wallet.ts @@ -144,21 +144,26 @@ class RailgunWallet extends AbstractWallet { * @param {string} encryptionKey - encryption key to use with database * @param {bigint} chainId - Chain ID for the ephemeral key * @param {number} index - index of derivation path + * @param {string} mnemonicPassword - BIP-39 mnemonic password, if the wallet uses one * @returns {Promise} */ async getEphemeralWallet( encryptionKey: string, chainId: bigint, index: number, + mnemonicPassword?: string, ): Promise { const { mnemonic, index: railgunIndex } = (await AbstractWallet.read( this.db, this.id, encryptionKey, )) as WalletData; + // The mnemonic password is never persisted; a wrong/missing one would silently derive a + // different, unrelated ephemeral key. Fail loudly, as the spending-key path does. + RailgunWallet.assertMnemonicPasswordMatchesID(this.id, mnemonic, railgunIndex, mnemonicPassword); const basePath = getEphemeralWalletBasePath(railgunIndex, chainId); const pathSuffix = getEphemeralSignerPathSuffix(this.ephemeralSignerProvider, index); - return deriveEphemeralWalletFromPathSuffix(mnemonic, basePath, pathSuffix); + return deriveEphemeralWalletFromPathSuffix(mnemonic, basePath, pathSuffix, mnemonicPassword); } /** @@ -204,17 +209,21 @@ class RailgunWallet extends AbstractWallet { async getCurrentEphemeralAddress( encryptionKey: string, chainId: bigint, + mnemonicPassword?: string, ): Promise { - const signer = await this.getCurrentEphemeralSigner(encryptionKey, chainId); + const signer = await this.getCurrentEphemeralSigner(encryptionKey, chainId, mnemonicPassword); return signer.address; } async getCurrentEphemeralSigner( encryptionKey: string, chainId: bigint, + mnemonicPassword?: string, ): Promise { const ephemeralIndex = await this.getEphemeralKeyIndex(chainId); if (this.ephemeralSignerProvider.getSigner) { + // Custom signer providers (e.g. hardware wallets) control their own keys and do not + // derive from the mnemonic, so the mnemonic password does not apply to this branch. const { index: railgunAccountIndex } = (await AbstractWallet.read( this.db, this.id, @@ -228,7 +237,7 @@ class RailgunWallet extends AbstractWallet { }); } - return this.getEphemeralWallet(encryptionKey, chainId, ephemeralIndex); + return this.getEphemeralWallet(encryptionKey, chainId, ephemeralIndex, mnemonicPassword); } /** @@ -239,13 +248,14 @@ class RailgunWallet extends AbstractWallet { async getCurrentEphemeralWallet( encryptionKey: string, chainId: bigint, + mnemonicPassword?: string, ): Promise { if (this.ephemeralWalletOverride) { return this.ephemeralWalletOverride; } const index = await this.getEphemeralKeyIndex(chainId); - return this.getEphemeralWallet(encryptionKey, chainId, index); + return this.getEphemeralWallet(encryptionKey, chainId, index, mnemonicPassword); } setEphemeralSignerProvider(provider: EphemeralSignerProvider): void { @@ -300,8 +310,13 @@ class RailgunWallet extends AbstractWallet { executionType: DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, executeNonce: 0n, }, + mnemonicPassword?: string, ): Promise<{ authorization: Authorization; signature: string }> { - const ephemeralSigner = await this.getCurrentEphemeralSigner(encryptionKey, chainId); + const ephemeralSigner = await this.getCurrentEphemeralSigner( + encryptionKey, + chainId, + mnemonicPassword, + ); const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( ephemeralSigner, From ce5d929c2b6191b38fb8945807bce2a9b3b776b0 Mon Sep 17 00:00:00 2001 From: zy0n Date: Mon, 6 Jul 2026 12:54:33 +0000 Subject: [PATCH 7/8] test(7702): pin on-chain execution-signature digest The existing signature tests sign and verify with the same off-chain code, so drift in the EIP-712 domain, the Execute type string, or the payload ABI encoding would move both sides together and still pass. Reconstruct the digest from first principles and pin the encoding and digest as golden vectors so any drift fails the default test run. --- .../relay-adapt-7702-signature.test.ts | 120 +++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/src/transaction/__tests__/relay-adapt-7702-signature.test.ts b/src/transaction/__tests__/relay-adapt-7702-signature.test.ts index f7994091..bf41900d 100644 --- a/src/transaction/__tests__/relay-adapt-7702-signature.test.ts +++ b/src/transaction/__tests__/relay-adapt-7702-signature.test.ts @@ -1,5 +1,14 @@ import { expect } from 'chai'; -import { AbiCoder, Wallet, keccak256, verifyTypedData } from 'ethers'; +import { + AbiCoder, + TypedDataEncoder, + Wallet, + concat, + keccak256, + recoverAddress, + toUtf8Bytes, + verifyTypedData, +} from 'ethers'; import { ACTION_DATA_STRUCT_ABI, RelayAdapt7702ExecutionType, @@ -152,3 +161,112 @@ describe('RelayAdapt7702 Execution Signature', () => { expect(recovered).to.equal(signer.address); }); }); + +// Regression guard for the off-chain <-> on-chain execution-signature digest (finding F1). +// +// A 7702 `execute` reverts unless the digest signed off-chain is byte-identical to the one the +// deployed RelayAdapt7702 contract recovers against. The tests above sign AND verify with the +// SAME off-chain code, so they would still pass if the domain, the `Execute` type string, or the +// payload ABI-encoding drifted away from the on-chain spec (both sides move together). The +// assertions below instead reconstruct the digest from first principles — the literal EIP-712 +// type string and domain, independent of the production signing path — and pin the exact encoding +// and digest as golden vectors, so any drift is caught by the default `npm test` run. The +// byte-exact match to the *deployed* contract is proven additionally by the RUN_HARDHAT_TESTS +// integration test in contracts/relay-adapt/__tests__/relay-adapt-7702.test.ts. +describe('RelayAdapt7702 execution signature — on-chain digest binding (F1)', () => { + // Deterministic signer (Hardhat account #1 key) so the digest is a stable golden vector. + const signer = new Wallet('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'); + const chainId = 1n; + const transactions: TransactionStructV2[] = []; + const actionData: RelayAdapt7702.ActionDataStruct = { + requireSuccess: true, + minGasLimit: 100000n, + calls: [], + }; + // nonce > 0 is the production steady state — the ephemeral account is reused across spends. + const executeNonce = 7n; + const executionDetails = { + executionType: RelayAdapt7702ExecutionType.ExecuteWithNonce, + executeNonce, + }; + const abiCoder = AbiCoder.defaultAbiCoder(); + + it('pins the struct ABI encoding of the signed payload', () => { + // These strings are the on-chain `execute` calldata layout. Freezing them makes any + // typechain/ABI drift (which would silently change the payload hash) fail here. + expect(TRANSACTION_STRUCT_ABI).to.equal( + '(((uint256 x, uint256 y) a, (uint256[2] x, uint256[2] y) b, (uint256 x, uint256 y) c) proof, bytes32 merkleRoot, bytes32[] nullifiers, bytes32[] commitments, (uint16 treeNumber, uint72 minGasPrice, uint8 unshield, uint64 chainID, address adaptContract, bytes32 adaptParams, (bytes32[4] ciphertext, bytes32 blindedSenderViewingKey, bytes32 blindedReceiverViewingKey, bytes annotationData, bytes memo)[] commitmentCiphertext) boundParams, (bytes32 npk, (uint8 tokenType, address tokenAddress, uint256 tokenSubID) token, uint120 value) unshieldPreimage)', + ); + expect(ACTION_DATA_STRUCT_ABI).to.equal( + '(bool requireSuccess, uint256 minGasLimit, (address to, bytes data, uint256 value)[] calls) _actionData', + ); + }); + + it('binds the payload hash to abi.encode(transactions, actionData, nonce) and commits the nonce', () => { + const nonceAware = getExecutePayloadHash(transactions, actionData, executionDetails); + expect(nonceAware).to.equal( + keccak256( + abiCoder.encode( + [`${TRANSACTION_STRUCT_ABI}[]`, ACTION_DATA_STRUCT_ABI, 'uint256'], + [transactions, actionData, executeNonce], + ), + ), + ); + // Golden vector: fixed inputs -> stable hash. Any encoding drift breaks this. + expect(nonceAware).to.equal( + '0x58ade22424a8dfa6f288ed073f91564e66cbf2bca00f713340729eb31b35a3c7', + ); + + // The legacy payload MUST omit the nonce (and therefore differ). + const legacy = getExecutePayloadHash(transactions, actionData, { + executionType: RelayAdapt7702ExecutionType.LegacyPreExecuteNonce, + }); + expect(legacy).to.equal( + keccak256( + abiCoder.encode( + [`${TRANSACTION_STRUCT_ABI}[]`, ACTION_DATA_STRUCT_ABI], + [transactions, actionData], + ), + ), + ); + expect(legacy).to.not.equal(nonceAware); + }); + + it('signs the exact EIP-712 digest the contract recovers against (domain + Execute type)', async () => { + const payloadHash = getExecutePayloadHash(transactions, actionData, executionDetails); + + // Reconstruct the EIP-712 digest from first principles, independent of the production path. + const executeTypeHash = keccak256(toUtf8Bytes('Execute(bytes32 payloadHash)')); + const structHash = keccak256( + abiCoder.encode(['bytes32', 'bytes32'], [executeTypeHash, payloadHash]), + ); + const domain = { + name: 'RelayAdapt7702', + version: '1', + chainId, + verifyingContract: signer.address, + }; + const domainSeparator = TypedDataEncoder.hashDomain(domain); + const expectedDigest = keccak256(concat(['0x1901', domainSeparator, structHash])); + + // ethers' typed-data hash (what the signer signs) must equal the manual envelope: this pins + // the domain fields and the `Execute(bytes32 payloadHash)` type string to the on-chain spec. + const types = { Execute: [{ name: 'payloadHash', type: 'bytes32' }] }; + expect(TypedDataEncoder.hash(domain, types, { payloadHash })).to.equal(expectedDigest); + + // Golden digest vector for the fixed signer + inputs. + expect(expectedDigest).to.equal( + '0xcf1b811e7309f5b4bdbbd10c5097f2ebb5e90cae59e724e214f61bdfcd115410', + ); + + // The production signer must sign THAT digest — recovery to the signer proves it. + const signature = await signExecutionAuthorization( + signer, + transactions, + actionData, + chainId, + executionDetails, + ); + expect(recoverAddress(expectedDigest, signature)).to.equal(signer.address); + }); +}); From 0316ad24aa99a3434a37b2452cd59561a5313c40 Mon Sep 17 00:00:00 2001 From: zy0n Date: Tue, 14 Jul 2026 19:58:47 +0000 Subject: [PATCH 8/8] fix(7702): honor pinned ephemeral override when signing the authorization getCurrentEphemeralSigner ignored ephemeralWalletOverride, so a pinned ephemeral signed the EIP-7702 authorization with a different (HD-derived) account than the one driving tx.to and the nonce. The authorization authority no longer matched tx.to and a paymaster-sponsored execute no-oped on-chain (authorization Validity: False, zero logs). Resolve the override first, winning over a custom signer provider and mirroring getCurrentEphemeralWallet's precedence, so the authorization authority equals tx.to. Cover the override path and override-over-provider precedence with regression tests. --- src/wallet/__tests__/railgun-wallet.test.ts | 34 ++++++++++++++++++++- src/wallet/railgun-wallet.ts | 10 ++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/wallet/__tests__/railgun-wallet.test.ts b/src/wallet/__tests__/railgun-wallet.test.ts index 0868570a..46cd90cc 100644 --- a/src/wallet/__tests__/railgun-wallet.test.ts +++ b/src/wallet/__tests__/railgun-wallet.test.ts @@ -1,7 +1,7 @@ import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { utf8ToBytes } from 'ethereum-cryptography/utils'; -import { Authorization, Signature as EthSignature } from 'ethers'; +import { Authorization, HDNodeWallet, Signature as EthSignature } from 'ethers'; import memdown from 'memdown'; import { getNoteBlindingKeys, @@ -533,6 +533,38 @@ describe('railgun-wallet', () => { expect(typedDataVerifyingContract).to.equal('0x1111111111111111111111111111111111111111'); }); + it('Should resolve the ephemeral signer and address to a pinned override', async () => { + // A pinned override must drive BOTH the tx target/nonce (getCurrentEphemeralWallet) and the + // authorization signer (getCurrentEphemeralSigner). If the signer ignored the override, the + // recovered EIP-7702 authority would differ from `to` and a sponsored execute would no-op. + const override = HDNodeWallet.createRandom(); + await wallet.setCurrentEphemeralWallet(override); + + const signer = await wallet.getCurrentEphemeralSigner(testEncryptionKey, 1n); + expect(signer.address).to.equal(override.address); + expect(await wallet.getCurrentEphemeralAddress(testEncryptionKey, 1n)).to.equal( + override.address, + ); + + const ephemeralWallet = await wallet.getCurrentEphemeralWallet(testEncryptionKey, 1n); + expect(ephemeralWallet.address).to.equal(override.address); + }); + + it('Should prefer a pinned override over an injected signer provider for the signer', async () => { + const override = HDNodeWallet.createRandom(); + await wallet.setCurrentEphemeralWallet(override); + wallet.setEphemeralSignerProvider({ + getPathSuffix: (index) => `${index}'`, + getDBPathSuffix: () => ['ledger'], + getSigner: async () => { + throw new Error('signer provider must not be consulted when an override is set'); + }, + }); + + const signer = await wallet.getCurrentEphemeralSigner(testEncryptionKey, 1n); + expect(signer.address).to.equal(override.address); + }); + it('Should reject invalid ephemeral key index path suffixes', async () => { wallet.setEphemeralSignerProvider({ getPathSuffix: (index) => `${index}'`, diff --git a/src/wallet/railgun-wallet.ts b/src/wallet/railgun-wallet.ts index b60f4c2b..dca54a71 100644 --- a/src/wallet/railgun-wallet.ts +++ b/src/wallet/railgun-wallet.ts @@ -220,6 +220,16 @@ class RailgunWallet extends AbstractWallet { chainId: bigint, mnemonicPassword?: string, ): Promise { + // The EIP-7702 authorization authority MUST equal the tx `to` and the account whose nonce + // is read. getCurrentEphemeralWallet (which drives `to` and the nonce) returns the override + // first, so the signer that produces the authorization must honor the same override first — + // otherwise a pinned ephemeral signs the authorization with a different (HD-derived) account, + // authority != `to`, and a sponsored execute no-ops on-chain. Override wins over a custom + // signer provider, matching getCurrentEphemeralWallet's precedence. + if (this.ephemeralWalletOverride) { + return this.ephemeralWalletOverride; + } + const ephemeralIndex = await this.getEphemeralKeyIndex(chainId); if (this.ephemeralSignerProvider.getSigner) { // Custom signer providers (e.g. hardware wallets) control their own keys and do not