From a70b9df85857f15cca1cc3fbdfeb344f2346b646 Mon Sep 17 00:00:00 2001 From: Prabhsharan Singh Date: Mon, 26 Oct 2026 19:23:51 +0530 Subject: [PATCH] fix(abstract-eth): verify ERC-7984 wrap/unwrap/finalizeUnwrap on multisig (CHALO-1957) Onchain multisig verifyTransaction only exempted wrapApprove from the txParams.recipients requirement, so wrap, unwrap and finalizeUnwrap legs could never pass verification and ended in 'missing params'. Add an explicit multisig path for the three intent types. It is content-based on the sendMultiSig args in txHex: wrapper target resolved from a registry scoped to the wallet's network, self-directed base address rules, and well-formed calldata fields. The user key signs the operation hash over txPrebuild.recipients[0], not over txHex, so the prebuild must carry exactly one recipient whose address, amount and data equal the decoded sendMultiSig args. The same strict check now also applies to the existing wrapApprove path. Ticket: CHALO-1957 --- .../src/abstractEthLikeNewCoins.ts | 315 ++++++++++++- modules/sdk-coin-eth/test/unit/eth.ts | 435 +++++++++++++++++- 2 files changed, 725 insertions(+), 25 deletions(-) diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 0bb42d4d0c..8360491ff6 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -86,8 +86,11 @@ import { coinFamiliesWithL1Fees, approveMethodId, decodeApproveCalldata, + decodeFinalizeUnwrapCalldata, decodeNativeTransferData, decodeTransferData, + decodeUnwrapCalldata, + decodeWrapCalldata, ERC1155TransferBuilder, ERC721TransferBuilder, getBufferedByteCode, @@ -100,6 +103,7 @@ import { sendMultisigMethodId, TransactionBuilder, TransferBuilder, + UINT64_MAX, } from './lib'; import { SendCrossChainRecoveryOptions } from './types'; @@ -392,6 +396,8 @@ interface EthTransactionParams extends TransactionParams { defiParams?: DefiIntentParams; /** ERC-7984 shield intent (`type: 'wrapApprove' | 'wrap'`): wrapper coin name and exact amount. */ wrapParams?: { tokenName: string; amount: string }; + /** ERC-7984 unshield intent (`type: 'unwrap'`): wrapper coin name and plaintext amount. */ + unwrapParams?: { tokenName: string; amount: string }; } export interface VerifyEthTransactionOptions extends VerifyTransactionOptions { @@ -504,6 +510,33 @@ function getErc7984WrappersByUnderlying(): ReadonlyMap; + addresses: ReadonlySet; +} + +const erc7984WrappersByNetwork = new WeakMap(); + +/** + * Lowercased wrapper contract addresses of every registered ERC-7984 pair on one network, by pair name and as + * a set. Keyed on the network, not on `underlyingErc20Address` (only some pairs carry it), so a mainnet + * wallet never accepts a testnet wrapper and every intent type resolves wrappers by the same rule. + */ +function getErc7984WrappersForNetwork(network: EthLikeNetwork): Erc7984NetworkWrappers { + let wrappers = erc7984WrappersByNetwork.get(network); + if (!wrappers) { + const byTokenName = new Map(); + coins.forEach((coin) => { + if (coin instanceof Erc7984Coin && coin.contractAddress && coin.network === network) { + byTokenName.set(coin.name, coin.contractAddress.toLowerCase()); + } + }); + wrappers = { byTokenName, addresses: new Set(byTokenName.values()) }; + erc7984WrappersByNetwork.set(network, wrappers); + } + return wrappers; +} + export const optionalDeps = { get ethAbi() { try { @@ -3747,6 +3780,15 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem return true; } + // ERC-7984 intent legs (wrap / unwrap / finalizeUnwrap) for non-TSS (onchain multisig) wallets: + // calldata is derived server-side from the intent or the journey, so no client recipients exist. + // Content-based on the sendMultiSig args instead of the generic recipient guards. + if (txParams?.type === 'wrap' || txParams?.type === 'unwrap' || txParams?.type === 'finalizeUnwrap') { + await this.verifyMultisigErc7984IntentTransaction(params, throwRecipientMismatch); + await this.throwIfPrebuildCoinMismatch(txParams, txPrebuild); + return true; + } + if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) { throw new Error('missing params'); } @@ -4051,30 +4093,263 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem await mismatch('wrapApprove transaction amount does not match the requested amount', innerTo, approve.amount); } - // Cross-check the build recipients the client signs the operation hash against — they must - // mirror the sendMultiSig args encoded in txHex. - const prebuildRecipient = txPrebuild.recipients?.[0]; - if (prebuildRecipient) { - const recipientDetail = [ - { address: prebuildRecipient.address, amount: prebuildRecipient.amount?.toString() ?? '' }, - ]; - if (prebuildRecipient.address?.toLowerCase() !== innerTo.toLowerCase()) { - await throwRecipientMismatch( - 'wrapApprove prebuild recipient address does not match the sendMultiSig target encoded in txHex', - recipientDetail - ); + await this.assertPrebuildRecipientMirrorsSendMultiSig( + 'wrapApprove', + txPrebuild, + { to: innerTo, amount: innerAmount, data: innerData }, + throwRecipientMismatch + ); + } + + /** + * Verifies an ERC-7984 intent leg (wrap / unwrap / finalizeUnwrap) for non-TSS (onchain multisig) wallets. + * + * Multisig shape (server-built unsigned tx): + * tx.to = wallet contract + * tx.data = sendMultiSig(wrapper, 0, , expireTime, sequenceId, signature) + * txPrebuild.recipients[0] = { address: wrapper, amount: '0', data: intent calldata } + * + * Client recipients never exist for these intents: user-leg builds derive the calldata from + * wrapParams/unwrapParams, and server-leg PA rebuilds re-derive everything from the journey + * (the PA carries only the type and pendingApprovalId). Verification is therefore content-based + * on the sendMultiSig args: + * - unwrap: strict — inner target must be the registered pair's wrapper for unwrapParams.tokenName + * and the unwrap args must be self-directed (wallet base address). + * - wrap: strict when wrapParams is present (target wrapper + amount); bounded otherwise — + * inner target must be a registered ERC-7984 wrapper and the mint recipient the wallet base. + * - finalizeUnwrap: bounded — registered wrapper target and well-formed request fields. + */ + private async verifyMultisigErc7984IntentTransaction( + params: VerifyEthTransactionOptions, + throwRecipientMismatch: (message: string, mismatchedRecipients: Recipient[]) => Promise + ): Promise { + const { txParams, txPrebuild } = params; + if (!txParams) { + throw new Error('missing params'); + } + const type = txParams.type as 'wrap' | 'unwrap' | 'finalizeUnwrap'; + const intentParams = type === 'unwrap' ? txParams.unwrapParams : txParams.wrapParams; + if (!txPrebuild?.txHex) { + throw new Error('missing txHex in txPrebuild'); + } + if (type === 'unwrap' && !intentParams?.tokenName) { + return throwRecipientMismatch('unwrap transaction requires unwrapParams with tokenName', []); + } + + const txBuilder = this.getTransactionBuilder(); + txBuilder.from(txPrebuild.txHex); + const txJson = (await txBuilder.build()).toJson(); + + let innerTo: string; + let innerAmount: string; + let innerData: string; + const data = txJson.data || ''; + if (!data.toLowerCase().startsWith(sendMultisigMethodId.toLowerCase())) { + return throwRecipientMismatch( + `${type} multisig transaction must encode the ${type} call inside sendMultiSig`, + [] + ); + } + + try { + const decoded = decodeNativeTransferData(data); + innerTo = decoded.to; + innerAmount = decoded.amount; + innerData = decoded.data; + } catch (e) { + return throwRecipientMismatch( + `${type} multisig transaction failed to decode sendMultiSig calldata — ${(e as Error).message}`, + [] + ); + } + + const mismatch = (message: string, address = innerTo, amount = innerAmount): Promise => + throwRecipientMismatch(message, [{ address: address || '', amount: amount || '' }]); + if (innerAmount !== '0') { + await mismatch(`${type} transaction must not carry native value inside sendMultiSig`); + } + + const baseAddress = this.getVerifyWalletBaseAddress(params.wallet); + if (!baseAddress) { + throw new Error('missing required coin specific property baseAddress'); + } + const network = this.getNetwork(); + if (!network) { + throw new Error('missing network for ERC-7984 intent verification'); + } + const { byTokenName: wrapperByTokenName, addresses: registeredWrappers } = getErc7984WrappersForNetwork(network); + + switch (type) { + case 'wrap': { + let decoded: { to: string; amount: string }; + try { + decoded = decodeWrapCalldata(innerData || ''); + } catch (e) { + return mismatch( + `wrap transaction must use ERC-7984 wrap(address,uint256) calldata — ${(e as Error).message}` + ); + } + if (decoded.to.toLowerCase() !== baseAddress.toLowerCase()) { + await mismatch('wrap transaction mint recipient must be the wallet base address', decoded.to, decoded.amount); + } + if (!/^\d+$/.test(decoded.amount) || BigInt(decoded.amount) <= 0n) { + await mismatch('wrap transaction amount must be a positive integer', decoded.to, decoded.amount); + } + if (intentParams?.tokenName) { + const wrapper = wrapperByTokenName.get(intentParams.tokenName); + if (!wrapper) { + return throwRecipientMismatch( + `wrap: '${intentParams.tokenName}' is not a registered ERC-7984 shield pair`, + [] + ); + } + if (innerTo.toLowerCase() !== wrapper) { + await mismatch('wrap transaction must target the registered wrapper contract'); + } + if (decoded.amount !== intentParams.amount) { + await mismatch('wrap transaction amount does not match the requested amount', decoded.to, decoded.amount); + } + } else if (!registeredWrappers.has(innerTo.toLowerCase())) { + await mismatch('wrap transaction must target a registered ERC-7984 wrapper'); + } + break; } - let recipientData: string | undefined; - if ('data' in prebuildRecipient && typeof prebuildRecipient.data === 'string') { - recipientData = prebuildRecipient.data; + case 'unwrap': { + const wrapper = wrapperByTokenName.get(intentParams!.tokenName); + if (!wrapper) { + return throwRecipientMismatch( + `unwrap: '${intentParams!.tokenName}' is not a registered ERC-7984 shield pair`, + [] + ); + } + if (innerTo.toLowerCase() !== wrapper) { + await mismatch('unwrap transaction must target the registered wrapper contract'); + } + let decoded: { from: string; to: string; encryptedAmount: string; inputProof: string }; + try { + decoded = decodeUnwrapCalldata(innerData || ''); + } catch (e) { + return mismatch( + `unwrap transaction must use ERC-7984 unwrap(address,address,bytes32,bytes) calldata — ${ + (e as Error).message + }` + ); + } + if ( + decoded.from.toLowerCase() !== baseAddress.toLowerCase() || + decoded.to.toLowerCase() !== baseAddress.toLowerCase() + ) { + await mismatch('unwrap transaction must be self-directed to the wallet base address'); + } + if (!decoded.encryptedAmount || /^0x0+$/.test(decoded.encryptedAmount)) { + await mismatch('unwrap transaction encryptedAmount is missing or empty'); + } + if (!decoded.inputProof || decoded.inputProof === '0x') { + await mismatch('unwrap transaction inputProof is missing or empty'); + } + break; } - if (recipientData && stripHexPrefix(recipientData).toLowerCase() !== stripHexPrefix(innerData).toLowerCase()) { - await throwRecipientMismatch( - 'wrapApprove prebuild recipient data does not match the sendMultiSig calldata encoded in txHex', - recipientDetail - ); + case 'finalizeUnwrap': { + if (!registeredWrappers.has(innerTo.toLowerCase())) { + await mismatch('finalizeUnwrap transaction must target a registered ERC-7984 wrapper'); + } + let decoded: { requestId: string; cleartextAmount: string; decryptionProof: string }; + try { + decoded = decodeFinalizeUnwrapCalldata(innerData || ''); + } catch (e) { + return mismatch( + `finalizeUnwrap transaction must use ERC-7984 finalizeUnwrap(bytes32,uint64,bytes) calldata — ${ + (e as Error).message + }` + ); + } + if (!decoded.requestId || /^0x0+$/.test(decoded.requestId)) { + await mismatch('finalizeUnwrap transaction requestId is missing or empty'); + } + if ( + !/^\d+$/.test(decoded.cleartextAmount) || + BigInt(decoded.cleartextAmount) <= 0n || + BigInt(decoded.cleartextAmount) > UINT64_MAX + ) { + await mismatch( + 'finalizeUnwrap transaction cleartextAmount must be a positive integer within uint64', + innerTo, + decoded.cleartextAmount + ); + } + if (!decoded.decryptionProof || decoded.decryptionProof === '0x') { + await mismatch('finalizeUnwrap transaction decryptionProof is missing or empty'); + } + break; } + default: + return throwRecipientMismatch(`unsupported ERC-7984 intent type '${type}'`, []); + } + + await this.assertPrebuildRecipientMirrorsSendMultiSig( + type, + txPrebuild, + { to: innerTo, amount: innerAmount, data: innerData }, + throwRecipientMismatch + ); + } + + /** + * The user key signs the sendMultiSig operation hash over `txPrebuild.recipients[0]` (address, amount, data), + * not over txHex, so the verified sendMultiSig args only protect the signer when that recipient mirrors them + * exactly. Exactly one recipient is required, and a missing recipient or missing calldata is a mismatch. + */ + private async assertPrebuildRecipientMirrorsSendMultiSig( + label: string, + txPrebuild: VerifyEthTransactionOptions['txPrebuild'], + inner: { to: string; amount: string; data: string }, + throwRecipientMismatch: (message: string, mismatchedRecipients: Recipient[]) => Promise + ): Promise { + // Same resolution order as the signer (Eth.signTransaction), so what is verified is what gets signed. + const recipients = txPrebuild.recipients || txPrebuild.txInfo?.recipients; + if (!Array.isArray(recipients) || recipients.length !== 1) { + return throwRecipientMismatch( + `${label} prebuild must carry exactly one recipient mirroring the sendMultiSig args encoded in txHex`, + [] + ); + } + const recipient = recipients[0]; + const detail = [{ address: recipient.address, amount: recipient.amount?.toString() ?? '' }]; + if (recipient.address?.toLowerCase() !== inner.to.toLowerCase()) { + return throwRecipientMismatch( + `${label} prebuild recipient address does not match the sendMultiSig target encoded in txHex`, + detail + ); + } + if (!new BigNumber(recipient.amount).isEqualTo(inner.amount)) { + return throwRecipientMismatch( + `${label} prebuild recipient amount does not match the sendMultiSig value encoded in txHex`, + detail + ); + } + const recipientData = 'data' in recipient && typeof recipient.data === 'string' ? recipient.data : undefined; + if (recipientData === undefined) { + return throwRecipientMismatch( + `${label} prebuild recipient data is missing; it must mirror the sendMultiSig calldata encoded in txHex`, + detail + ); + } + if (stripHexPrefix(recipientData).toLowerCase() !== stripHexPrefix(inner.data).toLowerCase()) { + return throwRecipientMismatch( + `${label} prebuild recipient data does not match the sendMultiSig calldata encoded in txHex`, + detail + ); + } + } + + /** Wallet base (contract) address for onchain multisig verify paths; undefined when absent. */ + private getVerifyWalletBaseAddress(wallet: VerifyEthTransactionOptions['wallet']): string | undefined { + if (!wallet) { + return undefined; } + const coinSpecific = typeof wallet.coinSpecific === 'function' ? wallet.coinSpecific() : wallet.coinSpecific; + const ethCoinSpecific = coinSpecific as { baseAddress?: string; rootAddress?: string } | undefined; + return ethCoinSpecific?.baseAddress ?? ethCoinSpecific?.rootAddress; } private isETHAddress(address: string): boolean { diff --git a/modules/sdk-coin-eth/test/unit/eth.ts b/modules/sdk-coin-eth/test/unit/eth.ts index e72cdc799a..3bae6ed1cb 100644 --- a/modules/sdk-coin-eth/test/unit/eth.ts +++ b/modules/sdk-coin-eth/test/unit/eth.ts @@ -26,9 +26,11 @@ import { AbstractEthLikeNewCoins, Erc20Token, Erc721Token, + Eth, Hteth, Teth, TransactionBuilder, + VerifyEthTransactionOptions, TransferBuilder, TssVerifyEthAddressOptions, UnsignedBuilConsolidation, @@ -62,8 +64,12 @@ describe('ETH:', function () { const userReqSig = '0x404db307f6147f0d8cd338c34c13906ef46a6faa7e0e119d5194ef05aec16e6f3d710f9b7901460f97e924066b62efd74443bd34402c6d40b49c203a559ff2c8'; - const buildDefiTxHex = async (data: string, to: string = address1): Promise => { - const txBuilder = getBuilder('hteth') as TransactionBuilder; + const buildDefiTxHex = async ( + data: string, + to: string = address1, + coinName: 'eth' | 'hteth' = 'hteth' + ): Promise => { + const txBuilder = getBuilder(coinName) as TransactionBuilder; txBuilder.type(TransactionType.ContractCall); txBuilder.fee({ fee: '10', gasLimit: '100000' }); txBuilder.counter(1); @@ -97,6 +103,7 @@ describe('ETH:', function () { Erc20Token.createTokenConstructors().forEach(({ name, coinConstructor }) => { bitgo.safeRegister(name, coinConstructor); }); + bitgo.safeRegister('eth', Eth.createInstance); bitgo.safeRegister('teth', Teth.createInstance); bitgo.safeRegister('hteth', Hteth.createInstance); common.Environments[env].hsmXpub = bitgoXpub; @@ -1110,10 +1117,23 @@ describe('ETH:', function () { result.should.equal(true); }); - it('should verify approve wrapped in sendMultiSig without prebuild recipients', async function () { + it('should reject approve wrapped in sendMultiSig when the prebuild carries no recipient', async function () { const data = sendMultiSigCalldata(underlying, '0', approveCalldata(wrapper, amount)); - const result = await verifyMultisigWrapApprove(data); - result.should.equal(true); + await expectDefiRejection(verifyMultisigWrapApprove(data), /exactly one recipient/); + }); + + it('should reject a prebuild recipient with missing data or a non-zero amount', async function () { + const data = sendMultiSigCalldata(underlying, '0', approveCalldata(wrapper, amount)); + await expectDefiRejection( + verifyMultisigWrapApprove(data, { txPrebuildRecipients: [{ address: underlying, amount: '0' }] }), + /recipient data is missing/ + ); + await expectDefiRejection( + verifyMultisigWrapApprove(data, { + txPrebuildRecipients: [{ address: underlying, amount: '1', data: approveCalldata(wrapper, amount) }], + }), + /recipient amount does not match/ + ); }); it('should reject a spender other than the wrapper inside sendMultiSig', async function () { @@ -1183,6 +1203,409 @@ describe('ETH:', function () { }); }); + describe('multisig ERC-7984 intent verification (wrap / unwrap / finalizeUnwrap)', function () { + const pair = coins.get('hteth:cusdt') as Erc7984Coin; + const wrapper = String(pair.contractAddress); + const amount = '1000000'; + const walletContract = address2; + const baseAddress = address1; + const requestId = '0x' + 'ab'.repeat(32); + const encryptedAmount = '0x' + 'cd'.repeat(32); + const inputProof = '0xdeadbeef'; + const decryptionProof = '0xbeefcafe'; + + const sendMultiSigCalldata = (toAddress: string, value: string, innerData: string): string => { + const types = ['address', 'uint', 'bytes', 'uint', 'uint', 'bytes']; + const methodId = EthereumAbi.methodID('sendMultiSig', types); + const encoded = EthereumAbi.rawEncode(types, [ + toAddress, + new BN(value), + Buffer.from(innerData.slice(2), 'hex'), + 1234, + 1, + Buffer.alloc(0), + ]); + return '0x' + Buffer.concat([methodId, encoded]).toString('hex'); + }; + + const intentCalldata = (signature: string, values: unknown[]): string => { + const types = signature.slice(signature.indexOf('(') + 1, -1).split(','); + const methodId = EthereumAbi.methodID(signature.split('(')[0], types); + return '0x' + Buffer.concat([methodId, EthereumAbi.rawEncode(types, values)]).toString('hex'); + }; + + const verifyIntent = async ( + type: 'wrap' | 'unwrap' | 'finalizeUnwrap', + innerData: string, + opts: { + to?: string; + value?: string; + coinName?: 'eth' | 'hteth'; + wrapParams?: { tokenName: string; amount: string } | null; + unwrapParams?: { tokenName: string; amount: string } | null; + txPrebuildRecipients?: Array>; + omitRecipients?: boolean; + } = {} + ) => { + const coinName = opts.coinName ?? 'hteth'; + const coin = bitgo.coin(coinName) as Hteth; + const wallet = new Wallet(bitgo, coin, { coinSpecific: { baseAddress } }); + return coin.verifyTransaction({ + txParams: { + type, + ...(opts.wrapParams !== undefined ? { wrapParams: opts.wrapParams } : {}), + ...(opts.unwrapParams !== undefined ? { unwrapParams: opts.unwrapParams } : {}), + } as unknown as VerifyEthTransactionOptions['txParams'], + txPrebuild: { + txHex: await buildDefiTxHex( + sendMultiSigCalldata(opts.to ?? wrapper, opts.value ?? '0', innerData), + walletContract, + coinName + ), + coin: coinName, + walletId: 'w', + // test fixture: by default the prebuild carries the single recipient the user key signs over, + // mirroring the sendMultiSig args (address, value, calldata) + ...(opts.omitRecipients + ? {} + : { + recipients: opts.txPrebuildRecipients ?? [ + { address: opts.to ?? wrapper, amount: opts.value ?? '0', data: innerData }, + ], + }), + } as unknown as VerifyEthTransactionOptions['txPrebuild'], + wallet, + verification: {}, + }); + }; + + it('should verify a server-leg wrap rebuild (registered wrapper, no intent params)', async function () { + const result = await verifyIntent('wrap', intentCalldata('wrap(address,uint256)', [baseAddress, amount]), { + txPrebuildRecipients: [ + { address: wrapper, amount: '0', data: intentCalldata('wrap(address,uint256)', [baseAddress, amount]) }, + ], + }); + result.should.equal(true); + }); + + it('should verify a wrap carrying matching wrapParams', async function () { + const result = await verifyIntent('wrap', intentCalldata('wrap(address,uint256)', [baseAddress, amount]), { + wrapParams: { tokenName: 'hteth:cusdt', amount }, + }); + result.should.equal(true); + }); + + it('should reject a wrap whose amount does not match wrapParams', async function () { + await expectDefiRejection( + verifyIntent('wrap', intentCalldata('wrap(address,uint256)', [baseAddress, amount]), { + wrapParams: { tokenName: 'hteth:cusdt', amount: '1000001' }, + }), + /amount does not match/ + ); + }); + + it('should reject a wrap targeting a non-registered contract', async function () { + await expectDefiRejection( + verifyIntent('wrap', intentCalldata('wrap(address,uint256)', [baseAddress, amount]), { to: address2 }), + /must target a registered ERC-7984 wrapper/ + ); + }); + + it('should reject a wrap minting to an address other than the wallet base', async function () { + await expectDefiRejection( + verifyIntent('wrap', intentCalldata('wrap(address,uint256)', [address2, amount])), + /mint recipient must be the wallet base address/ + ); + }); + + it('should reject inner calldata that is not wrap', async function () { + await expectDefiRejection( + verifyIntent('wrap', intentCalldata('transfer(address,uint256)', [address2, amount])), + /wrap\(address,uint256\)/ + ); + }); + + it('should reject native value inside sendMultiSig', async function () { + await expectDefiRejection( + verifyIntent('wrap', intentCalldata('wrap(address,uint256)', [baseAddress, amount]), { value: '1' }), + /must not carry native value/ + ); + }); + + it('should reject a wrap that is not sendMultiSig-wrapped', async function () { + const coin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, coin, { coinSpecific: { baseAddress } }); + await expectDefiRejection( + coin.verifyTransaction({ + txParams: { type: 'wrap' }, + txPrebuild: { + txHex: await buildDefiTxHex(intentCalldata('wrap(address,uint256)', [baseAddress, amount]), wrapper), + coin: 'hteth', + walletId: 'w', + } as unknown as VerifyEthTransactionOptions['txPrebuild'], + wallet, + verification: {}, + }), + /sendMultiSig/ + ); + }); + + it('should verify a self-directed unwrap to the registered wrapper', async function () { + const result = await verifyIntent( + 'unwrap', + intentCalldata('unwrap(address,address,bytes32,bytes)', [ + baseAddress, + baseAddress, + Buffer.from(encryptedAmount.slice(2), 'hex'), + Buffer.from(inputProof.slice(2), 'hex'), + ]), + { unwrapParams: { tokenName: 'hteth:cusdt', amount } } + ); + result.should.equal(true); + }); + + it('should reject an unwrap without unwrapParams', async function () { + await expectDefiRejection( + verifyIntent( + 'unwrap', + intentCalldata('unwrap(address,address,bytes32,bytes)', [ + baseAddress, + baseAddress, + Buffer.from(encryptedAmount.slice(2), 'hex'), + Buffer.from(inputProof.slice(2), 'hex'), + ]) + ), + /requires unwrapParams/ + ); + }); + + it('should reject an unwrap targeting a contract other than the registered wrapper', async function () { + await expectDefiRejection( + verifyIntent( + 'unwrap', + intentCalldata('unwrap(address,address,bytes32,bytes)', [ + baseAddress, + baseAddress, + Buffer.from(encryptedAmount.slice(2), 'hex'), + Buffer.from(inputProof.slice(2), 'hex'), + ]), + { to: address2, unwrapParams: { tokenName: 'hteth:cusdt', amount } } + ), + /must target the registered wrapper/ + ); + }); + + it('should reject an unwrap naming an unregistered pair', async function () { + await expectDefiRejection( + verifyIntent( + 'unwrap', + intentCalldata('unwrap(address,address,bytes32,bytes)', [ + baseAddress, + baseAddress, + Buffer.from(encryptedAmount.slice(2), 'hex'), + Buffer.from(inputProof.slice(2), 'hex'), + ]), + { unwrapParams: { tokenName: 'hteth', amount } } + ), + /not a registered ERC-7984 shield pair/ + ); + }); + + it('should reject a non-self-directed unwrap', async function () { + await expectDefiRejection( + verifyIntent( + 'unwrap', + intentCalldata('unwrap(address,address,bytes32,bytes)', [ + baseAddress, + address2, + Buffer.from(encryptedAmount.slice(2), 'hex'), + Buffer.from(inputProof.slice(2), 'hex'), + ]), + { unwrapParams: { tokenName: 'hteth:cusdt', amount } } + ), + /self-directed/ + ); + }); + + it('should reject an unwrap with an empty encrypted amount', async function () { + await expectDefiRejection( + verifyIntent( + 'unwrap', + intentCalldata('unwrap(address,address,bytes32,bytes)', [ + baseAddress, + baseAddress, + Buffer.alloc(32), + Buffer.from(inputProof.slice(2), 'hex'), + ]), + { unwrapParams: { tokenName: 'hteth:cusdt', amount } } + ), + /encryptedAmount is missing or empty/ + ); + }); + + it('should verify a finalizeUnwrap to a registered wrapper', async function () { + const result = await verifyIntent( + 'finalizeUnwrap', + intentCalldata('finalizeUnwrap(bytes32,uint64,bytes)', [ + Buffer.from(requestId.slice(2), 'hex'), + amount, + Buffer.from(decryptionProof.slice(2), 'hex'), + ]) + ); + result.should.equal(true); + }); + + it('should reject a finalizeUnwrap targeting a non-registered contract', async function () { + await expectDefiRejection( + verifyIntent( + 'finalizeUnwrap', + intentCalldata('finalizeUnwrap(bytes32,uint64,bytes)', [ + Buffer.from(requestId.slice(2), 'hex'), + amount, + Buffer.from(decryptionProof.slice(2), 'hex'), + ]), + { to: address2 } + ), + /must target a registered ERC-7984 wrapper/ + ); + }); + + it('should reject a finalizeUnwrap with a zero cleartext amount', async function () { + await expectDefiRejection( + verifyIntent( + 'finalizeUnwrap', + intentCalldata('finalizeUnwrap(bytes32,uint64,bytes)', [ + Buffer.from(requestId.slice(2), 'hex'), + '0', + Buffer.from(decryptionProof.slice(2), 'hex'), + ]) + ), + /positive integer within uint64/ + ); + }); + + it('should reject a finalizeUnwrap with an empty decryption proof', async function () { + await expectDefiRejection( + verifyIntent( + 'finalizeUnwrap', + intentCalldata('finalizeUnwrap(bytes32,uint64,bytes)', [ + Buffer.from(requestId.slice(2), 'hex'), + amount, + Buffer.alloc(0), + ]) + ), + /decryptionProof is missing or empty/ + ); + }); + + it('should reject prebuild recipients that do not mirror the sendMultiSig args in txHex', async function () { + const inner = intentCalldata('wrap(address,uint256)', [baseAddress, amount]); + await expectDefiRejection( + verifyIntent('wrap', inner, { txPrebuildRecipients: [{ address: address2, amount: '0', data: inner }] }), + /recipient address does not match the sendMultiSig target/ + ); + await expectDefiRejection( + verifyIntent('wrap', inner, { + txPrebuildRecipients: [ + { + address: wrapper, + amount: '0', + data: intentCalldata('wrap(address,uint256)', [baseAddress, '1000001']), + }, + ], + }), + /recipient data does not match the sendMultiSig calldata/ + ); + }); + + it('should reject a prebuild with no recipient, several recipients, missing data or a non-zero amount', async function () { + const inner = intentCalldata('wrap(address,uint256)', [baseAddress, amount]); + await expectDefiRejection(verifyIntent('wrap', inner, { omitRecipients: true }), /exactly one recipient/); + await expectDefiRejection( + verifyIntent('wrap', inner, { + txPrebuildRecipients: [ + { address: wrapper, amount: '0', data: inner }, + { address: wrapper, amount: '0', data: inner }, + ], + }), + /exactly one recipient/ + ); + await expectDefiRejection( + verifyIntent('wrap', inner, { txPrebuildRecipients: [{ address: wrapper, amount: '0' }] }), + /recipient data is missing/ + ); + await expectDefiRejection( + verifyIntent('wrap', inner, { txPrebuildRecipients: [{ address: wrapper, amount: '1', data: inner }] }), + /recipient amount does not match the sendMultiSig value/ + ); + }); + + describe('wrapper registry is scoped to the wallet network', function () { + const mainnetPair = coins.get('eth:cusdt') as Erc7984Coin; + const mainnetWrapper = String(mainnetPair.contractAddress); + const unwrapCalldata = intentCalldata('unwrap(address,address,bytes32,bytes)', [ + baseAddress, + baseAddress, + Buffer.from(encryptedAmount.slice(2), 'hex'), + Buffer.from(inputProof.slice(2), 'hex'), + ]); + const finalizeCalldata = intentCalldata('finalizeUnwrap(bytes32,uint64,bytes)', [ + Buffer.from(requestId.slice(2), 'hex'), + amount, + Buffer.from(decryptionProof.slice(2), 'hex'), + ]); + const wrapCalldata = intentCalldata('wrap(address,uint256)', [baseAddress, amount]); + + it('should verify unwrap, finalizeUnwrap and wrap against a mainnet pair without underlyingErc20Address', async function () { + (mainnetPair.underlyingErc20Address === undefined).should.equal(true); + ( + await verifyIntent('unwrap', unwrapCalldata, { + coinName: 'eth', + to: mainnetWrapper, + unwrapParams: { tokenName: 'eth:cusdt', amount }, + }) + ).should.equal(true); + ( + await verifyIntent('finalizeUnwrap', finalizeCalldata, { coinName: 'eth', to: mainnetWrapper }) + ).should.equal(true); + (await verifyIntent('wrap', wrapCalldata, { coinName: 'eth', to: mainnetWrapper })).should.equal(true); + }); + + it('should reject a testnet wrapper on a mainnet wallet for every intent type', async function () { + await expectDefiRejection( + verifyIntent('finalizeUnwrap', finalizeCalldata, { coinName: 'eth', to: wrapper }), + /must target a registered ERC-7984 wrapper/ + ); + await expectDefiRejection( + verifyIntent('wrap', wrapCalldata, { coinName: 'eth', to: wrapper }), + /must target a registered ERC-7984 wrapper/ + ); + await expectDefiRejection( + verifyIntent('unwrap', unwrapCalldata, { + coinName: 'eth', + to: wrapper, + unwrapParams: { tokenName: 'eth:cusdt', amount }, + }), + /must target the registered wrapper/ + ); + }); + + it('should reject a mainnet wrapper on a testnet wallet and a cross-network pair name', async function () { + await expectDefiRejection( + verifyIntent('finalizeUnwrap', finalizeCalldata, { to: mainnetWrapper }), + /must target a registered ERC-7984 wrapper/ + ); + await expectDefiRejection( + verifyIntent('unwrap', unwrapCalldata, { + coinName: 'eth', + unwrapParams: { tokenName: 'hteth:cusdt', amount }, + }), + /not a registered ERC-7984 shield pair/ + ); + }); + }); + }); + describe('wrapper-directed approve must be declared wrapApprove', function () { const pair = coins.get('hteth:cusdt') as Erc7984Coin; const wrapper = String(pair.contractAddress); @@ -1380,6 +1803,8 @@ describe('ETH:', function () { txHex: await buildDefiTxHex(sendMultiSigCalldata(underlying, approveCalldata(wrapper)), walletContract), coin: 'hteth', walletId: 'w', + // the user key signs over this recipient, so a real prebuild always carries it + recipients: [{ address: underlying, amount: '0', data: approveCalldata(wrapper) }], } as any, wallet, verification: {},