Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions modules/abstract-eth/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
111 changes: 111 additions & 0 deletions modules/sdk-coin-eth/src/erc7984Token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
decodeTokenAddressesFromDelegationCalldata,
decodeConfidentialTransferData,
decodeDirectConfidentialTransferCalldata,
decodeFlushERC7984ForwarderTokenCalldata,
decodeSendMultiSigFlushERC7984Data,
sendMultisigMethodId,
confidentialTransferWithProofMethodId,
VerifyEthTransactionOptions,
Expand Down Expand Up @@ -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<boolean> {
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.
*
Expand Down
116 changes: 115 additions & 1 deletion modules/sdk-coin-eth/test/unit/erc7984Token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> {
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<string> {
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/);
});
});
Loading