From a82b3349dc7ec6fe5023736dc86a6141a1229de8 Mon Sep 17 00:00:00 2001 From: Mohammed Ryaan Date: Thu, 4 Jun 2026 11:51:14 +0530 Subject: [PATCH] fix(abstract-eth): fix verifyTransaction for base multig address during enable token flow TICKET: CHALO-530 --- .../src/abstractEthLikeNewCoins.ts | 81 ++++++++- modules/sdk-coin-eth/test/unit/eth.ts | 167 ++++++++++++++++++ 2 files changed, 247 insertions(+), 1 deletion(-) diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 4cb72147f8..29e656d990 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -3289,7 +3289,12 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { `${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.` ); } - if (txParams.hop && txPrebuild.hopTransaction) { + if (txParams.type === 'enabletoken') { + // ERC-7984 token enablement: intercept before hop/batch/single routing. + // Handles both base-address (txPrebuild → ACL) and forwarder (txPrebuild → forwarder) wallets, + // for single or multiple tokens at once. + await this.verifyMultisigEnableTokenTransaction(params, throwRecipientMismatch); + } else if (txParams.hop && txPrebuild.hopTransaction) { // Check recipient amount for hop transaction if (recipients.length !== 1) { throw new Error(`hop transaction only supports 1 recipient but ${recipients.length} found`); @@ -3388,6 +3393,80 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { * @param address * @returns {boolean} */ + /** + * Verifies an ERC-7984 token enablement (decryption delegation) transaction for multisig wallets. + * + * Two wallet shapes are supported: + * + * Base-address wallet — txPrebuild targets the Zama ACL contract directly: + * txParams.recipients : one entry per token, all with the wallet base address, amount '0' + * txPrebuild.recipients[0] : the Zama ACL contract address + * + * Forwarder wallet — txPrebuild targets the forwarder which calls the ACL via callFromParent: + * txParams.recipients : one entry per token, all with the forwarder address, amount '0' + * txPrebuild.recipients[0] : the forwarder address (matches txParams recipients) + * + * In both cases all amounts must be 0 and all txParams recipients must share the same address. + */ + private async verifyMultisigEnableTokenTransaction( + params: VerifyEthTransactionOptions, + throwRecipientMismatch: (message: string, mismatchedRecipients: Recipient[]) => Promise + ): Promise { + const { txParams, txPrebuild } = params; + const recipients = txParams.recipients!; + + if (!recipients || recipients.length === 0) { + throw new Error('token enablement transaction must have at least one recipient in txParams'); + } + + // All txParams recipients must have amount 0 + for (const r of recipients) { + if (!new BigNumber(r.amount).isEqualTo(0)) { + await throwRecipientMismatch('token enablement txParams recipients must all have amount 0', [ + { address: r.address, amount: String(r.amount) }, + ]); + } + } + + // txPrebuild recipient amount must be 0 + if (!new BigNumber(txPrebuild.recipients[0].amount).isEqualTo(0)) { + await throwRecipientMismatch('token enablement transaction expected amount 0 in txPrebuild', [ + { address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }, + ]); + } + + // All txParams recipients must share the same address — + // a single delegation tx covers one wallet, not multiple wallets simultaneously. + const firstAddress = recipients[0].address.toLowerCase(); + for (const r of recipients) { + if (r.address.toLowerCase() !== firstAddress) { + await throwRecipientMismatch('token enablement txParams recipients must all have the same address', [ + { address: r.address, amount: String(r.amount) }, + ]); + } + } + + const prebuildAddress = txPrebuild.recipients[0].address.toLowerCase(); + const zamaAclContractAddress = (this.getNetwork() as EthLikeNetwork)?.zamaAclContractAddress; + + // Base-address wallet: txPrebuild targets the Zama ACL contract directly. + // txParams recipients carry the wallet base address — the intentional address mismatch is expected. + if (zamaAclContractAddress && prebuildAddress === zamaAclContractAddress.toLowerCase()) { + return; + } + + // Forwarder wallet: txPrebuild targets the forwarder address, which must match txParams recipients. + if (prebuildAddress === firstAddress) { + return; + } + + // Neither recognised shape — txPrebuild address is not the ACL contract and doesn't match txParams + await throwRecipientMismatch( + 'token enablement txPrebuild recipient must be the Zama ACL contract (base-address wallet) or match txParams recipients (forwarder wallet)', + [{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }] + ); + } + private isETHAddress(address: string): boolean { return !!address.match(/0x[a-fA-F0-9]{40}/); } diff --git a/modules/sdk-coin-eth/test/unit/eth.ts b/modules/sdk-coin-eth/test/unit/eth.ts index d801cf24fa..65df323b44 100644 --- a/modules/sdk-coin-eth/test/unit/eth.ts +++ b/modules/sdk-coin-eth/test/unit/eth.ts @@ -2647,4 +2647,171 @@ describe('ETH:', function () { assert.strictEqual(payload.length, 32); }); }); + + // --------------------------------------------------------------------------- + // ERC-7984 token enablement — multisig verifyTransaction (all scenarios) + // --------------------------------------------------------------------------- + describe('verifyTransaction – ERC-7984 enabletoken (multisig wallet)', function () { + // hteth Zama ACL contract (Networks.test.hoodi.zamaAclContractAddress) + const ZAMA_ACL_ADDRESS = '0x6d3faf6f86e1ff9f3b0831dda920aba1cbd5bd68'; + // Simulated wallet base address (what the client sends as the recipient for base-address wallets) + const WALLET_BASE_ADDRESS = '0x08e6736e876d772d2fa8f803a076cd6c9f845546'; + // Simulated forwarder address (what the client sends as recipient for forwarder wallets) + const FORWARDER_ADDRESS = '0x1234567890123456789012345678901234567890'; + const WRONG_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + const makePrebuild = (recipientAddress: string) => ({ + recipients: [{ address: recipientAddress, amount: '0' }], + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 300000, + isBatch: false, + coin: 'hteth', + walletId: 'fakeWalletId', + }); + + // ── Base-address wallet ────────────────────────────────────────────────── + + it('base-address, single token: txParams → walletBase, txPrebuild → ACL', async function () { + const htethCoin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } }); + + const result = await htethCoin.verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [{ address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' }], + } as any, + txPrebuild: makePrebuild(ZAMA_ACL_ADDRESS) as any, + wallet, + }); + result.should.equal(true); + }); + + it('base-address, multi-token: multiple txParams recipients (same base address), txPrebuild → ACL multicall', async function () { + const htethCoin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } }); + + const result = await htethCoin.verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [ + { address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' }, + { address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:cusdt' }, + ], + } as any, + txPrebuild: makePrebuild(ZAMA_ACL_ADDRESS) as any, + wallet, + }); + result.should.equal(true); + }); + + // ── Forwarder wallet ───────────────────────────────────────────────────── + + it('forwarder, single token: txParams → forwarder, txPrebuild → forwarder', async function () { + const htethCoin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } }); + + const result = await htethCoin.verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [{ address: FORWARDER_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' }], + } as any, + txPrebuild: makePrebuild(FORWARDER_ADDRESS) as any, + wallet, + }); + result.should.equal(true); + }); + + it('forwarder, multi-token: multiple txParams recipients (same forwarder), txPrebuild → forwarder', async function () { + const htethCoin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } }); + + const result = await htethCoin.verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [ + { address: FORWARDER_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' }, + { address: FORWARDER_ADDRESS, amount: '0', tokenName: 'hteth:cusdt' }, + ], + } as any, + txPrebuild: makePrebuild(FORWARDER_ADDRESS) as any, + wallet, + }); + result.should.equal(true); + }); + + // ── Negative cases ─────────────────────────────────────────────────────── + + it('should throw when txPrebuild recipient is neither the ACL contract nor the forwarder from txParams', async function () { + const htethCoin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } }); + + await htethCoin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [{ address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' }], + } as any, + txPrebuild: makePrebuild(WRONG_ADDRESS) as any, + wallet, + }) + .should.be.rejectedWith(/token enablement txPrebuild recipient must be/); + }); + + it('should throw when txParams recipients have different addresses', async function () { + const htethCoin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } }); + + await htethCoin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [ + { address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' }, + { address: FORWARDER_ADDRESS, amount: '0', tokenName: 'hteth:cusdt' }, + ], + } as any, + txPrebuild: makePrebuild(ZAMA_ACL_ADDRESS) as any, + wallet, + }) + .should.be.rejectedWith(/must all have the same address/); + }); + + it('should throw when a txParams recipient has non-zero amount', async function () { + const htethCoin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } }); + + await htethCoin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [{ address: WALLET_BASE_ADDRESS, amount: '100', tokenName: 'hteth:ctest1' }], + } as any, + txPrebuild: makePrebuild(ZAMA_ACL_ADDRESS) as any, + wallet, + }) + .should.be.rejectedWith(/must all have amount 0/); + }); + + it('should throw when txPrebuild recipient has non-zero amount', async function () { + const htethCoin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } }); + + await htethCoin + .verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [{ address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' }], + } as any, + txPrebuild: { + recipients: [{ address: ZAMA_ACL_ADDRESS, amount: '500' }], + nextContractSequenceId: 0, + coin: 'hteth', + walletId: 'fakeWalletId', + } as any, + wallet, + }) + .should.be.rejectedWith(/expected amount 0 in txPrebuild/); + }); + }); });