diff --git a/src/__tests__/railgun-engine.test.ts b/src/__tests__/railgun-engine.test.ts index 11607a01..bef18663 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.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/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..46a81c42 --- /dev/null +++ b/src/abi/V2/RelayAdapt7702.json @@ -0,0 +1,874 @@ +[ + { + "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": "uint256", + "name": "_nonce", + "type": "uint256" + }, + { + "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" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + } + ], + "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/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 01eee115..48b2faa3 100644 --- a/src/abi/abi.ts +++ b/src/abi/abi.ts @@ -4,6 +4,9 @@ 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 ABIRelayAdapt7702_Legacy_PreExecuteNonce from './V2/RelayAdapt7702_Legacy_PreExecuteNonce.json'; +import ABIRegistry from './V2/Registry.json'; // V2.1 import ABIRailgunSmartWallet from './V2.1/RailgunSmartWallet.json'; @@ -18,6 +21,9 @@ export { ABIRailgunSmartWallet_Legacy_PreMar23, ABIRailgunSmartWallet, ABIRelayAdapt, + ABIRelayAdapt7702, + ABIRelayAdapt7702_Legacy_PreExecuteNonce, + 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..eb23dd7e --- /dev/null +++ b/src/abi/typechain/RelayAdapt7702.ts @@ -0,0 +1,578 @@ +/* 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: + | "DOMAIN_SEPARATOR" + | "EXECUTE_TYPEHASH" + | "MULTICALL_TYPEHASH" + | "adaptImplementation" + | "execute" + | "getExecutePayloadHash" + | "getMulticallPayloadHash" + | "multicall" + | "nonce" + | "railgun" + | "shield" + | "transfer" + | "unwrapBase" + | "wBase" + | "wrapBase" + ): FunctionFragment; + + getEvent(nameOrSignatureOrTopic: "CallError"): EventFragment; + + 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, + BigNumberish, + BytesLike + ] + ): string; + encodeFunctionData( + functionFragment: "getExecutePayloadHash", + values: [TransactionStruct[], RelayAdapt7702.ActionDataStruct, BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "getMulticallPayloadHash", + values: [boolean, RelayAdapt7702.CallStruct[], BigNumberish] + ): 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: "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: "getMulticallPayloadHash", + 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; + + 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, + _nonce: BigNumberish, + _signature: BytesLike + ], + [void], + "payable" + >; + + getExecutePayloadHash: TypedContractMethod< + [ + _transactions: TransactionStruct[], + _actionData: RelayAdapt7702.ActionDataStruct, + _nonce: BigNumberish + ], + [string], + "view" + >; + + getMulticallPayloadHash: TypedContractMethod< + [ + _requireSuccess: boolean, + _calls: RelayAdapt7702.CallStruct[], + _nonce: BigNumberish + ], + [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: "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, + _nonce: BigNumberish, + _signature: BytesLike + ], + [void], + "payable" + >; + getFunction( + nameOrSignature: "getExecutePayloadHash" + ): TypedContractMethod< + [ + _transactions: TransactionStruct[], + _actionData: RelayAdapt7702.ActionDataStruct, + _nonce: BigNumberish + ], + [string], + "view" + >; + getFunction( + nameOrSignature: "getMulticallPayloadHash" + ): TypedContractMethod< + [ + _requireSuccess: boolean, + _calls: RelayAdapt7702.CallStruct[], + _nonce: BigNumberish + ], + [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..3fa0aeec --- /dev/null +++ b/src/abi/typechain/factories/RelayAdapt7702__factory.ts @@ -0,0 +1,897 @@ +/* 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: "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: "uint256", + name: "_nonce", + type: "uint256", + }, + { + 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", + }, + { + internalType: "uint256", + name: "_nonce", + type: "uint256", + }, + ], + 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", + }, +] 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..0a2d09aa --- /dev/null +++ b/src/contracts/relay-adapt/V2/relay-adapt-7702.ts @@ -0,0 +1,547 @@ +import { + AbiCoder, + Contract, + ContractTransaction, + Provider, + Interface, + TransactionRequest, + Result, + Log, + toUtf8String, + Authorization, +} from 'ethers'; +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'; +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'; +import { + DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, + RelayAdapt7702ExecutionType, +} from '../../../transaction/relay-adapt-7702-signature'; + +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: RelayAdapt7702; + + private readonly provider: Provider; + + private readonly relayAdapt7702Interface: Interface; + + private readonly executionType: RelayAdapt7702ExecutionType; + + 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, + 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, + relayAdapt7702ABI, + provider, + ) as unknown as RelayAdapt7702; + } + + async populateShieldBaseToken( + shieldRequest: ShieldRequestStruct, + authorization?: Authorization, + signature?: string, + random31Bytes?: string, + ephemeralAddress?: string, + executeNonce?: bigint, + ): Promise { + void random31Bytes; + + const orderedCalls: ContractTransaction[] = await this.getOrderedCallsForShieldBaseToken( + shieldRequest, + ephemeralAddress, + ); + return this.populateRelayMulticall( + orderedCalls, + { + value: shieldRequest.preimage.value, + }, + authorization, + signature, + ephemeralAddress, + executeNonce, + ); + } + + 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, + executeNonce?: bigint, + ): 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, + executeNonce, + ); + } + + /** + * @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, + executeNonce?: bigint, + ): 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, + executeNonce, + ); + + // 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, + executeNonce?: bigint, + ): 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, + executeNonce, + ); + } + + /** + * 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, + executeNonceOverride?: bigint, + ): Promise { + const actionData: RelayAdapt7702.ActionDataStruct = RelayAdapt7702Helper.getActionData( + requireSuccess, + calls, + minimumGasLimit, + ); + + const sig = signature ?? '0x'; + const executeNonce = this.executionType === RelayAdapt7702ExecutionType.ExecuteWithNonce + ? executeNonceOverride ?? (await this.getExecuteNonce(ephemeralAddress)) + : undefined; + + const data = RelayAdapt7702Helper.encodeExecute( + this.relayAdapt7702Interface, + transactions, + actionData, + sig, + { + executionType: this.executionType, + executeNonce, + }, + ); + + const populatedTransaction = { + to: this.address, + data, + ...overrides, + } as ContractTransaction; + + if (ephemeralAddress) { + populatedTransaction.to = ephemeralAddress; + } + + if (authorization) { + (populatedTransaction as any).authorizationList = [authorization]; + } + + return populatedTransaction; + } + + /** + * 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, + ABIRelayAdapt7702, + this.provider, + ) as unknown as RelayAdapt7702; + return nonceContract.nonce(); + } + + 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-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 new file mode 100644 index 00000000..1e32a778 --- /dev/null +++ b/src/contracts/relay-adapt/__tests__/relay-adapt-7702.test.ts @@ -0,0 +1,1967 @@ +/* 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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..1b0ab8a1 --- /dev/null +++ b/src/contracts/relay-adapt/relay-adapt-7702-helper.ts @@ -0,0 +1,189 @@ +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'; +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 { + RelayAdapt7702ExecutionDetails, + RelayAdapt7702ExecutionType, + getExecutePayloadHash, + signExecutionAuthorization as signExecutionAuthorizationCore, + ZERO_7702_ADAPT_PARAMS, +} from '../../transaction/relay-adapt-7702-signature'; +import type { RelayAdapt7702HookedSigner } from '../../transaction/relay-adapt-7702-signer'; + +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: RelayAdapt7702HookedSigner, + contractAddress: string, + chainId: bigint, + nonce: number, + ): Promise { + return signEIP7702AuthorizationCore(signer, contractAddress, chainId, nonce); + } + + static async signExecutionAuthorization( + signer: RelayAdapt7702HookedSigner, + transactions: (TransactionStructV2 | TransactionStructV3)[], + actionData: RelayAdapt7702.ActionDataStruct, + chainId: bigint, + executionDetails?: RelayAdapt7702ExecutionDetails, + ): Promise { + return signExecutionAuthorizationCore(signer, transactions, actionData, chainId, executionDetails); + } + + static getExecutePayloadHash( + transactions: (TransactionStructV2 | TransactionStructV3)[], + actionData: RelayAdapt7702.ActionDataStruct, + executionDetails?: RelayAdapt7702ExecutionDetails, + ): string { + 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 { + 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( + requireSuccess: boolean, + calls: ContractTransaction[], + minGasLimit: bigint, + ): RelayAdapt7702.ActionDataStruct { + 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..7c70d7ac 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,27 @@ export class RelayAdaptVersionedSmartContracts { txidVersion: TXIDVersion, chain: Chain, shieldRequest: ShieldRequestStruct, + isRelayAdapt7702 = false, + authorization?: Authorization, + signature?: string, + random31Bytes?: string, + ephemeralAddress?: string, + executeNonce?: bigint, ): 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, + executeNonce, + ); + } + return this.getRelayAdaptContract(txidVersion, chain, isRelayAdapt7702).populateShieldBaseToken( + shieldRequest, + ); } static populateUnshieldBaseToken( @@ -36,9 +59,28 @@ export class RelayAdaptVersionedSmartContracts { random31Bytes: string, useDummyProof: boolean, sendWithPublicWallet: boolean, + isRelayAdapt7702 = false, + authorization?: Authorization, + signature?: string, + ephemeralAddress?: string, + executeNonce?: bigint, ): 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, + executeNonce, + ); + } const contractV2 = ContractStore.relayAdaptV2Contracts.getOrThrow(null, chain); return contractV2.populateUnshieldBaseToken( transactions as TransactionStructV2[], @@ -70,9 +112,30 @@ export class RelayAdaptVersionedSmartContracts { isGasEstimate: boolean, isBroadcasterTransaction: boolean, minGasLimit?: bigint, + isRelayAdapt7702 = false, + authorization?: Authorization, + signature?: string, + ephemeralAddress?: string, + executeNonce?: bigint, ): 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, + executeNonce, + ); + } const contractV2 = ContractStore.relayAdaptV2Contracts.getOrThrow(null, chain); return contractV2.populateCrossContractCalls( unshieldTransactions as TransactionStructV2[], @@ -107,9 +170,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 +214,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 +260,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 +276,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 +291,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..5943dbaa --- /dev/null +++ b/src/key-derivation/__tests__/ephemeral-key.test.ts @@ -0,0 +1,155 @@ +import { expect } from 'chai'; +import { + deriveEphemeralWallet, + deriveEphemeralWalletFromPathSuffix, + getEphemeralWalletBasePath, + getEphemeralWalletPathSuffix, +} 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.'); + }); + + 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', + ); + }); + + 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 new file mode 100644 index 00000000..d83f9415 --- /dev/null +++ b/src/key-derivation/ephemeral-key.ts @@ -0,0 +1,94 @@ +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}'`; +}; + +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, + mnemonicPassword?: string, +): HDNodeWallet => { + const normalizedPathSuffix = normalizeEphemeralWalletPathSuffix(pathSuffix); + const normalizedBasePath = basePath.replace(/\/+$/g, ''); + const path = `${normalizedBasePath}/${normalizedPathSuffix}`; + // 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); +}; + +/** + * 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) + * @param mnemonicPassword - BIP-39 mnemonic password, if the wallet uses one + * @returns HDNodeWallet + */ +export const deriveEphemeralWallet = ( + mnemonic: string, + railgunIndex: number, + chainId: bigint, + index: number, + mnemonicPassword?: string, +): HDNodeWallet => { + const basePath = getEphemeralWalletBasePath(railgunIndex, chainId); + const pathSuffix = getEphemeralWalletPathSuffix(index); + return deriveEphemeralWalletFromPathSuffix(mnemonic, basePath, pathSuffix, mnemonicPassword); +}; 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..56e0178d --- /dev/null +++ b/src/models/relay-adapt-types.ts @@ -0,0 +1,14 @@ +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[]; + actionData: RelayAdapt7702.ActionDataStruct; + authorization: Authorization; + executionSignature: string; + ephemeralAddress: string; + executionType: RelayAdapt7702ExecutionType; + executeNonce?: bigint; +} 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..f228ba00 100644 --- a/src/railgun-engine.ts +++ b/src/railgun-engine.ts @@ -3,6 +3,12 @@ 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 { RelayAdapt7702ExecutionType } from './transaction/relay-adapt-7702-signature'; +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 +647,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 +1527,9 @@ class RailgunEngine extends EventEmitter { deploymentBlocks: Record, poiLaunchBlock: Optional, supportsV3: boolean, + relayAdapt7702ContractAddress?: string, + railgunRegistryContractAddress?: string, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, ) { EngineDebug.log(`loadNetwork: ${chain.type}:${chain.id}`); @@ -1564,6 +1576,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 +1589,8 @@ class RailgunEngine extends EventEmitter { hasAnyMerkletree || hasSmartWalletContract || hasRelayAdaptV2Contract || + hasRelayAdapt7702Contract || + hasRegistryContract || hasPoseidonMerkleAccumulatorV3Contract || hasPoseidonMerkleVerifierV3Contract || hasTokenVaultV3Contract @@ -1601,6 +1617,30 @@ class RailgunEngine extends EventEmitter { new RelayAdaptV2Contract(relayAdaptV2ContractAddress, defaultProvider), ); + if (isDefined(relayAdapt7702ContractAddress)) { + ContractStore.relayAdapt7702Contracts.set( + null, + chain, + new RelayAdapt7702Contract( + relayAdapt7702ContractAddress, + defaultProvider, + relayAdapt7702ExecutionType, + ), + ); + } + + if (isDefined(railgunRegistryContractAddress)) { + ContractStore.railgunRegistryContract.set( + null, + chain, + new RegistryContract(railgunRegistryContractAddress, defaultProvider), + ); + } + + if (isDefined(relayAdapt7702ContractAddress) && isDefined(railgunRegistryContractAddress)) { + await this.warnIfRelayAdapt7702AddressMismatchesRegistry(chain, relayAdapt7702ContractAddress); + } + if (supportsV3) { ContractStore.poseidonMerkleAccumulatorV3Contracts.set( null, @@ -2306,6 +2346,90 @@ 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; + } + } + + /** + * 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; @@ -2315,6 +2439,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..dcc3c6ea 100644 --- a/src/test/config-overrides.test.ts +++ b/src/test/config-overrides.test.ts @@ -1 +1,21 @@ -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" + } +}; diff --git a/src/test/config.test.ts b/src/test/config.test.ts index cce5bcbb..59dfa615 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', + railgunRegistry: '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..f7994091 --- /dev/null +++ b/src/transaction/__tests__/relay-adapt-7702-signature.test.ts @@ -0,0 +1,154 @@ +import { expect } from 'chai'; +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'; + +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 executionDetails = { + executionType: RelayAdapt7702ExecutionType.ExecuteWithNonce, + executeNonce: 2n, + }; + const signature = await signExecutionAuthorization( + signer, + [mockTransaction], + mockActionData, + chainId, + executionDetails, + ); + + const recovered = verifyTypedData( + { + name: 'RelayAdapt7702', + version: '1', + chainId, + verifyingContract: signer.address, + }, + { + Execute: [{ name: 'payloadHash', type: 'bytes32' }], + }, + { + 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); + }); + + 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/eip7702.ts b/src/transaction/eip7702.ts new file mode 100644 index 00000000..676b269f --- /dev/null +++ b/src/transaction/eip7702.ts @@ -0,0 +1,28 @@ +import { Authorization } from 'ethers'; +import type { RelayAdapt7702HookedSigner } from './relay-adapt-7702-signer'; + +/** + * 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: RelayAdapt7702HookedSigner, + 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..dabf500a 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -1,3 +1,6 @@ // 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'; +export * from './relay-adapt-7702-signer'; diff --git a/src/transaction/relay-adapt-7702-signature.ts b/src/transaction/relay-adapt-7702-signature.ts new file mode 100644 index 00000000..515575c8 --- /dev/null +++ b/src/transaction/relay-adapt-7702-signature.ts @@ -0,0 +1,108 @@ +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', + 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) { + 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, + 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, 'uint256'], + [transactions, actionData, executeNonce], + ); + + 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: RelayAdapt7702HookedSigner, + transactions: (TransactionStructV2 | TransactionStructV3)[], + actionData: RelayAdapt7702.ActionDataStruct, + chainId: number | bigint, + executionDetails: RelayAdapt7702ExecutionDetails = { + executionType: DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, + executeNonce: 0n, + }, +): 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, executionDetails), + }); +}; 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/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 new file mode 100644 index 00000000..ffecd46f --- /dev/null +++ b/src/validation/__tests__/relay-adapt-7702-validator.test.ts @@ -0,0 +1,160 @@ +import { expect } from 'chai'; +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 ab75d802..df7bb3ab 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 } 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,18 +21,32 @@ 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'; +import { RelayAdapt7702Validator } from './relay-adapt-7702-validator'; enum TransactionName { RailgunSmartWallet = 'transact', RelayAdapt = 'relay', + 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: + if (relayAdapt7702ExecutionType === RelayAdapt7702ExecutionType.LegacyPreExecuteNonce) { + return [ABIRelayAdapt7702_Legacy_PreExecuteNonce]; + } + if (relayAdapt7702ExecutionType === RelayAdapt7702ExecutionType.ExecuteWithNonce) { + return [ABIRelayAdapt7702]; + } + return [ABIRelayAdapt7702, ABIRelayAdapt7702_Legacy_PreExecuteNonce]; } throw new Error('Unsupported transactionName'); }; @@ -40,8 +59,12 @@ export const extractFirstNoteERC20AmountMapFromTransactionRequestV2 = ( receivingViewingPrivateKey: Uint8Array, receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, + useRelayAdapt7702: boolean = false, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, ): Promise> => { - const transactionName = useRelayAdapt + const transactionName = useRelayAdapt7702 + ? TransactionName.RelayAdapt7702 + : useRelayAdapt ? TransactionName.RelayAdapt : TransactionName.RailgunSmartWallet; @@ -53,6 +76,7 @@ export const extractFirstNoteERC20AmountMapFromTransactionRequestV2 = ( receivingViewingPrivateKey, receivingRailgunAddressData, tokenDataGetter, + relayAdapt7702ExecutionType, ); }; @@ -64,8 +88,12 @@ export const extractRailgunTransactionDataFromTransactionRequestV2 = ( receivingViewingPrivateKey: Uint8Array, receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, + useRelayAdapt7702: boolean = false, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, ): Promise => { - const transactionName = useRelayAdapt + const transactionName = useRelayAdapt7702 + ? TransactionName.RelayAdapt7702 + : useRelayAdapt ? TransactionName.RelayAdapt : TransactionName.RailgunSmartWallet; @@ -77,16 +105,97 @@ 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'); +}; + +/** + * 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 abi = getABIForTransaction(transactionName); + const abis = getABIsForTransaction(transactionName, relayAdapt7702ExecutionType); if ( !transactionRequest.to || @@ -97,15 +206,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}`, @@ -115,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; @@ -136,6 +247,7 @@ const extractFirstNoteERC20AmountMapV2 = async ( receivingViewingPrivateKey: Uint8Array, receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, ): Promise> => { const erc20PaymentAmounts: Record = {}; @@ -144,6 +256,7 @@ const extractFirstNoteERC20AmountMapV2 = async ( transactionRequest, transactionName, contractAddress, + relayAdapt7702ExecutionType, ); await Promise.all( @@ -198,12 +311,15 @@ const extractRailgunTransactionDataV2 = async ( receivingViewingPrivateKey: Uint8Array, receivingRailgunAddressData: AddressData, tokenDataGetter: TokenDataGetter, + relayAdapt7702ExecutionType?: RelayAdapt7702ExecutionType, ): Promise => { const railgunTxs: TransactionStructOutput[] = getRailgunTransactionRequestsV2( chain, transactionRequest, 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/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..0c2d5072 --- /dev/null +++ b/src/validation/relay-adapt-7702-validator.ts @@ -0,0 +1,67 @@ +import { verifyTypedData, getBytes, keccak256, encodeRlp, toBeHex, recoverAddress, Authorization } from 'ethers'; +import { TransactionStructV2, TransactionStructV3 } from '../models/transaction-types'; +import { RelayAdapt7702 } from '../abi/typechain/RelayAdapt7702'; +import { + RelayAdapt7702ExecutionDetails, + getExecutePayloadHash, +} from '../transaction/relay-adapt-7702-signature'; + +export class RelayAdapt7702Validator { + static validateAuthorization( + authorization: Authorization, + expectedContractAddress: string, + expectedChainId: number | bigint, + expectedSigner?: string, + ): 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. 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 | TransactionStructV3)[], + actionData: RelayAdapt7702.ActionDataStruct, + signature: string, + chainId: number | bigint, + expectedSigner: string, + executionDetails?: RelayAdapt7702ExecutionDetails, + ): 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, executionDetails), + }, 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..4ab16dcc --- /dev/null +++ b/src/wallet/__tests__/ephemeral-key-manager.test.ts @@ -0,0 +1,94 @@ +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 incrementEphemeralKeyIndexStub: SinonStub; + let setEphemeralKeyIndexIfGreaterStub: SinonStub; + let getTransactionHistoryStub: SinonStub; + let isCanonicalEphemeralProviderStub: SinonStub; + let manager: EphemeralKeyManager; + + beforeEach(() => { + const railgunWallet = { + getEphemeralWallet: async () => {}, + incrementEphemeralKeyIndex: async () => {}, + setEphemeralKeyIndexIfGreater: async () => {}, + getTransactionHistory: async () => {}, + isCanonicalEphemeralProvider: () => true, + } as unknown as RailgunWallet; + + getEphemeralWalletStub = Sinon.stub(railgunWallet, 'getEphemeralWallet'); + 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); + }); + + 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); + incrementEphemeralKeyIndexStub.resolves(6); + getEphemeralWalletStub.resolves(mockWallet); + + const wallet = await manager.getNextWallet(BigInt(MOCK_CHAIN.id)); + expect(wallet.address).to.equal(mockWallet.address); + expect(incrementEphemeralKeyIndexStub.calledWith(BigInt(MOCK_CHAIN.id))).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 }], + }, + ]); + + setEphemeralKeyIndexIfGreaterStub.resolves(2); + + const recoveredIndex = await manager.scanHistoryForEphemeralIndex(MOCK_CHAIN, 100); + + expect(recoveredIndex).to.equal(2); + 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 9b65e197..0868570a 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, @@ -298,6 +299,273 @@ 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 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 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`; + }); + + 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 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}'`, + 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..1f5f6902 --- /dev/null +++ b/src/wallet/ephemeral-key-manager.ts @@ -0,0 +1,82 @@ +import { HDNodeWallet } from 'ethers'; +import { Chain } from '../models/engine-types'; +import { RailgunWallet } from './railgun-wallet'; + +export class EphemeralKeyManager { + private railgunWallet: RailgunWallet; + private encryptionKey: string; + private mnemonicPassword?: string; + + constructor(railgunWallet: RailgunWallet, encryptionKey: string, mnemonicPassword?: string) { + this.railgunWallet = railgunWallet; + this.encryptionKey = encryptionKey; + this.mnemonicPassword = mnemonicPassword; + } + + async getWallet(chainId: bigint, index: number): Promise { + return this.railgunWallet.getEphemeralWallet( + this.encryptionKey, + chainId, + index, + this.mnemonicPassword, + ); + } + + async getCurrentWallet(chainId: bigint): Promise { + const index = await this.railgunWallet.getEphemeralKeyIndex(chainId); + return this.getWallet(chainId, index); + } + + async getNextWallet(chainId: bigint): Promise { + const nextIndex = await this.railgunWallet.incrementEphemeralKeyIndex(chainId); + return this.getWallet(chainId, nextIndex); + } + + async scanHistoryForEphemeralIndex( + 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); + + 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; + } + + // Raise the stored index atomically so a concurrent ratchet cannot be clobbered. + const nextIndex = maxUsedIndex + 1; + return this.railgunWallet.setEphemeralKeyIndexIfGreater(chainId, 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..b60f4c2b 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,105 @@ 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'; +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, +) => string; + +// 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 = ( + 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; + getSigner?: (request: EphemeralSignerRequest) => Promise; +} + +const defaultEphemeralSignerProvider: EphemeralSignerProvider = { + getPathSuffix: getEphemeralWalletPathSuffix, + getDBPathSuffix: () => [], +}; 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 @@ -35,6 +139,272 @@ 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 + * @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, mnemonicPassword); + } + + /** + * 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'); + const parsed = parseInt(index as string, 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0; + } 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, + mnemonicPassword?: string, + ): Promise { + 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, + encryptionKey, + )) as WalletData; + return this.ephemeralSignerProvider.getSigner({ + railgunWalletID: this.id, + railgunAccountIndex, + chainId, + ephemeralIndex, + }); + } + + return this.getEphemeralWallet(encryptionKey, chainId, ephemeralIndex, mnemonicPassword); + } + + /** + * Get current ephemeral wallet + * @param {string} encryptionKey - encryption key to use with database + * @returns {Promise} + */ + 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, mnemonicPassword); + } + + setEphemeralSignerProvider(provider: EphemeralSignerProvider): void { + this.ephemeralSignerProvider = provider; + this.ephemeralProviderIsCustom = true; + } + + setEphemeralWalletDerivationStrategy(strategy: EphemeralWalletDerivationStrategy): void { + // 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; + } + + /** + * 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, + executionDetails: RelayAdapt7702ExecutionDetails = { + executionType: DEFAULT_RELAY_ADAPT_7702_EXECUTION_TYPE, + executeNonce: 0n, + }, + mnemonicPassword?: string, + ): Promise<{ authorization: Authorization; signature: string }> { + const ephemeralSigner = await this.getCurrentEphemeralSigner( + encryptionKey, + chainId, + mnemonicPassword, + ); + + const authorization = await RelayAdapt7702Helper.signEIP7702Authorization( + ephemeralSigner, + contractAddress, + chainId, + nonce, + ); + + const signature = await RelayAdapt7702Helper.signExecutionAuthorization( + ephemeralSigner, + transactions, + actionData, + chainId, + executionDetails, + ); + + 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} + */ + async ratchetEphemeralAddress( + chainId: bigint, + ): Promise { + await this.incrementEphemeralKeyIndex(chainId); + } + /** * Load encrypted node from database with encryption key * @param {BytesData} encryptionKey