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: {},