From 3dce7be2ba2de4e86d68b8c6bfbef2c41c5c1df5 Mon Sep 17 00:00:00 2001 From: Mohammed Ryaan Date: Wed, 1 Jul 2026 17:42:25 +0530 Subject: [PATCH] fix(sdk-coin-eth): update verify transaction for zama consolidation TICKET: CHALO-726 --- modules/abstract-eth/src/lib/utils.ts | 37 ++++++ modules/sdk-coin-eth/src/erc7984Token.ts | 111 +++++++++++++++++ .../sdk-coin-eth/test/unit/erc7984Token.ts | 116 +++++++++++++++++- 3 files changed, 263 insertions(+), 1 deletion(-) diff --git a/modules/abstract-eth/src/lib/utils.ts b/modules/abstract-eth/src/lib/utils.ts index 688131b290..208389ff4a 100644 --- a/modules/abstract-eth/src/lib/utils.ts +++ b/modules/abstract-eth/src/lib/utils.ts @@ -742,6 +742,43 @@ export function decodeConfidentialTransferData(data: string): ConfidentialTransf }; } +export interface SendMultiSigFlushERC7984Data { + forwarderAddress: string; + tokenContractAddress: string; + parentAddress: string; + encryptedHandle: string; +} + +/** + * Decode sendMultiSig-wrapped ERC-7984 forwarder consolidation (flush) calldata. + * + * Multisig consolidation shape: + * sendMultiSig(forwarder, 0, callFromParent(token, 0, confidentialTransferNoProof(parent, handle)), ...) + * + * @param data The full calldata hex string starting with sendMultiSigMethodId + */ +export function decodeSendMultiSigFlushERC7984Data(data: string): SendMultiSigFlushERC7984Data { + if (!data.startsWith(sendMultisigMethodId)) { + throw new BuildTransactionError(`Invalid multisig flush bytecode: unexpected method ID ${data.slice(0, 10)}`); + } + + const [forwarderAddress, , internalData] = getRawDecoded( + sendMultiSigTypes, + getBufferedByteCode(sendMultisigMethodId, data) + ); + + const internalDataHex = bufferToHex(internalData as Buffer); + const { tokenContractAddress, parentAddress, encryptedHandle } = + decodeFlushERC7984ForwarderTokenCalldata(internalDataHex); + + return { + forwarderAddress: addHexPrefix(forwarderAddress as string), + tokenContractAddress, + parentAddress, + encryptedHandle, + }; +} + export interface DirectConfidentialTransferData { toAddress: string; encryptedHandle: string; diff --git a/modules/sdk-coin-eth/src/erc7984Token.ts b/modules/sdk-coin-eth/src/erc7984Token.ts index fc9c09e13c..32a26d965c 100644 --- a/modules/sdk-coin-eth/src/erc7984Token.ts +++ b/modules/sdk-coin-eth/src/erc7984Token.ts @@ -10,6 +10,8 @@ import { decodeTokenAddressesFromDelegationCalldata, decodeConfidentialTransferData, decodeDirectConfidentialTransferCalldata, + decodeFlushERC7984ForwarderTokenCalldata, + decodeSendMultiSigFlushERC7984Data, sendMultisigMethodId, confidentialTransferWithProofMethodId, VerifyEthTransactionOptions, @@ -133,9 +135,118 @@ export class Erc7984Token extends Eth { if (params.txParams?.type === 'enabletoken') { return this.verifyEnableTokenTransaction(params); } + if (this.isConsolidationTransaction(params)) { + return this.verifyConfidentialConsolidation(params); + } return this.verifyConfidentialTransfer(params); } + private isConsolidationTransaction(params: VerifyEthTransactionOptions): boolean { + const { txParams, txPrebuild, verification } = params; + return !!( + verification?.consolidationToBaseAddress || + txPrebuild?.consolidateId || + txParams?.type === 'consolidate' || + txParams?.prebuildTx?.consolidateId + ); + } + + private getWalletBaseAddress(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; + } + + /** + * Verifies ERC-7984 forwarder consolidation (flush) transactions. + * + * Multisig shape: + * tx.to = wallet contract + * tx.data = sendMultiSig(forwarder, 0, callFromParent(token, 0, confidentialTransferNoProof(base, handle)), ...) + * + * TSS / direct shape: + * tx.to = forwarder contract + * tx.data = callFromParent(token, 0, confidentialTransferNoProof(base, handle)) + */ + private async verifyConfidentialConsolidation(params: VerifyEthTransactionOptions): Promise { + const { txParams, txPrebuild, wallet } = params; + + if (!txPrebuild?.txHex) { + if (!txPrebuild?.consolidateId && !txParams?.prebuildTx?.consolidateId) { + throw new Error('verifyConfidentialConsolidation: missing consolidateId'); + } + return true; + } + + const txBuilder = this.getTransactionBuilder(); + txBuilder.from(txPrebuild.txHex); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + let tokenContractAddress: string; + let parentAddress: string; + let encryptedHandle: string; + let forwarderAddress: string | undefined; + + try { + if (txJson.data.startsWith(sendMultisigMethodId)) { + const decoded = decodeSendMultiSigFlushERC7984Data(txJson.data); + forwarderAddress = decoded.forwarderAddress; + tokenContractAddress = decoded.tokenContractAddress; + parentAddress = decoded.parentAddress; + encryptedHandle = decoded.encryptedHandle; + } else if (txJson.data.startsWith(callFromParentMethodId)) { + const decoded = decodeFlushERC7984ForwarderTokenCalldata(txJson.data); + tokenContractAddress = decoded.tokenContractAddress; + parentAddress = decoded.parentAddress; + encryptedHandle = decoded.encryptedHandle; + forwarderAddress = txJson.to; + } else { + throw new Error(`unexpected method ID ${txJson.data.slice(0, 10)}`); + } + } catch (e) { + throw new Error( + `verifyConfidentialConsolidation: failed to decode consolidation calldata — ${(e as Error).message}` + ); + } + + if (tokenContractAddress.toLowerCase() !== this.tokenContractAddress.toLowerCase()) { + throw new Error( + `verifyConfidentialConsolidation: token contract address mismatch — ` + + `expected ${this.tokenContractAddress}, got ${tokenContractAddress}` + ); + } + + const baseAddress = this.getWalletBaseAddress(wallet); + if (!baseAddress) { + throw new Error('verifyConfidentialConsolidation: unable to determine wallet base address'); + } + if (parentAddress.toLowerCase() !== baseAddress.toLowerCase()) { + throw new Error( + `verifyConfidentialConsolidation: parent address mismatch — expected ${baseAddress}, got ${parentAddress}` + ); + } + + if (!encryptedHandle || encryptedHandle === '0x') { + throw new Error('verifyConfidentialConsolidation: encryptedHandle is missing or empty in transaction calldata'); + } + + const expectedForwarder = txPrebuild.recipients?.[0]?.address ?? txParams?.recipients?.[0]?.address; + if (forwarderAddress && expectedForwarder) { + if (forwarderAddress.toLowerCase() !== expectedForwarder.toLowerCase()) { + throw new Error( + `verifyConfidentialConsolidation: forwarder address mismatch — ` + + `expected ${expectedForwarder}, got ${forwarderAddress}` + ); + } + } + + return true; + } + /** * Verifies a confidential token transfer (SendERC7984) transaction. * diff --git a/modules/sdk-coin-eth/test/unit/erc7984Token.ts b/modules/sdk-coin-eth/test/unit/erc7984Token.ts index a866452346..1f66a76457 100644 --- a/modules/sdk-coin-eth/test/unit/erc7984Token.ts +++ b/modules/sdk-coin-eth/test/unit/erc7984Token.ts @@ -10,9 +10,11 @@ import should from 'should'; import { BitGoAPI } from '@bitgo/sdk-api'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; -import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionType, Wallet } from '@bitgo/sdk-core'; import { buildMulticallDelegationCalldata, + buildFlushERC7984ForwarderTokenCalldata, + sendMultiSigData, wrapInCallFromParent, decodeTokenAddressesFromDelegationCalldata, TransferBuilderERC7984, @@ -957,3 +959,115 @@ describe('decodeTokenAddressesFromDelegationCalldata', function () { ); }); }); + +// --------------------------------------------------------------------------- +// verifyTransaction – confidential consolidation (FlushERC7984ForwarderToken) +// --------------------------------------------------------------------------- + +const MULTISIG_WALLET_CONTRACT = '0x3b58684525564b38a381e46a731703ed03f32122'; +const CONSOLIDATION_FORWARDER = '0xf4bcb366bb5e34ebbee51fae5de98cc876c0146f'; +const CONSOLIDATION_BASE_ADDRESS = '0x3b58684525564b38a381e46a731703ed03f32122'; +const CONSOLIDATION_HANDLE = '0x65df136b609ab395c1a99ae46f7939c7f8b20dff4bff0000000088bb0050'; +const DUMMY_MULTISIG_SIGNATURE = '0x' + '00'.repeat(65); + +async function buildMultisigConsolidationTxHex(): Promise { + const flushCalldata = buildFlushERC7984ForwarderTokenCalldata( + CTEST1_TOKEN_ADDRESS, + CONSOLIDATION_BASE_ADDRESS, + CONSOLIDATION_HANDLE + ); + const sendData = sendMultiSigData( + CONSOLIDATION_FORWARDER, + '0', + flushCalldata, + Math.floor(Date.now() / 1000) + 3600, + 14, + DUMMY_MULTISIG_SIGNATURE + ); + + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '12100000' }); + txBuilder.counter(1); + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(MULTISIG_WALLET_CONTRACT); + txBuilder.data(sendData); + const tx = await txBuilder.build(); + return tx.toBroadcastFormat(); +} + +async function buildDirectConsolidationTxHex(): Promise { + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + txBuilder.type(TransactionType.FlushERC7984ForwarderToken); + txBuilder.contract(CONSOLIDATION_FORWARDER); + txBuilder.forwarderAddress(CONSOLIDATION_FORWARDER); + txBuilder.tokenContractAddress(CTEST1_TOKEN_ADDRESS); + txBuilder.parentAddress(CONSOLIDATION_BASE_ADDRESS); + txBuilder.encryptedHandle(CONSOLIDATION_HANDLE); + const tx = await txBuilder.build(); + return tx.toBroadcastFormat(); +} + +describe('verifyTransaction – confidential consolidation (FlushERC7984ForwarderToken)', function () { + let bitgo: TestBitGoAPI; + let coin: Erc7984Token; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.initializeTestVars(); + register(bitgo); + coin = bitgo.coin('hteth:ctest1') as Erc7984Token; + }); + + it('should verify a valid multisig consolidation tx (sendMultiSig → callFromParent → confidentialTransferNoProof)', async function () { + const txHex = await buildMultisigConsolidationTxHex(); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: CONSOLIDATION_BASE_ADDRESS }, + }); + + const result = await coin.verifyTransaction({ + txParams: { type: 'consolidate' } as any, + txPrebuild: { + consolidateId: '6a44ebc0326e1be45c1d797542c8c634', + txHex, + recipients: [{ address: CONSOLIDATION_FORWARDER, amount: '0' }], + } as any, + wallet, + }); + result.should.equal(true); + }); + + it('should verify a valid direct callFromParent consolidation tx (TSS shape)', async function () { + const txHex = await buildDirectConsolidationTxHex(); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: CONSOLIDATION_BASE_ADDRESS }, + }); + + const result = await coin.verifyTransaction({ + txParams: { type: 'consolidate' } as any, + txPrebuild: { + consolidateId: '6a44ebc0326e1be45c1d797542c8c634', + txHex, + } as any, + wallet, + verification: { consolidationToBaseAddress: true }, + }); + result.should.equal(true); + }); + + it('should reject multisig consolidation when parent address does not match wallet base address', async function () { + const txHex = await buildMultisigConsolidationTxHex(); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + }); + + await coin + .verifyTransaction({ + txParams: { type: 'consolidate' } as any, + txPrebuild: { consolidateId: 'abc123', txHex } as any, + wallet, + }) + .should.be.rejectedWith(/parent address mismatch/); + }); +});