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
34 changes: 34 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,40 @@ export function decodeConfidentialTransferData(data: string): ConfidentialTransf
};
}

export interface DirectConfidentialTransferData {
toAddress: string;
encryptedHandle: string;
inputProof: string;
}

/**
* Decode a direct confidentialTransfer(address, bytes32, bytes) calldata.
*
* Used for hot/TSS wallets where the signing address calls the token contract directly,
* without a sendMultiSig wrapper. In this path the `to` field of the EIP-1559 transaction
* is the token contract address and the calldata carries only the three transfer parameters.
*
* @param data 0x-prefixed calldata starting with confidentialTransferWithProofMethodId (0x2fb74e62)
* @returns toAddress, encryptedHandle, inputProof
*/
export function decodeDirectConfidentialTransferCalldata(data: string): DirectConfidentialTransferData {
if (!data.startsWith(confidentialTransferWithProofMethodId)) {
// Include only the 4-byte method ID in the error to avoid leaking encrypted payloads into logs.
throw new BuildTransactionError(
`Invalid direct confidential transfer calldata: unexpected method ID ${data.slice(0, 10)}`
);
}
const [toAddress, encryptedHandle, inputProof] = getRawDecoded(
confidentialTransferWithProofTypes,
getBufferedByteCode(confidentialTransferWithProofMethodId, data)
);
return {
toAddress: addHexPrefix(toAddress as string),
encryptedHandle: bufferToHex(encryptedHandle as Buffer),
inputProof: bufferToHex(inputProof as Buffer),
};
}

/**
* Decode a FlushERC7984ForwarderToken transaction's calldata into its component parts.
*
Expand Down
71 changes: 54 additions & 17 deletions modules/sdk-coin-eth/src/erc7984Token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
DecryptionDelegationBuilder,
decodeTokenAddressesFromDelegationCalldata,
decodeConfidentialTransferData,
decodeDirectConfidentialTransferCalldata,
sendMultisigMethodId,
confidentialTransferWithProofMethodId,
VerifyEthTransactionOptions,
aclMulticallMethodId,
callFromParentMethodId,
Expand Down Expand Up @@ -136,15 +139,25 @@ export class Erc7984Token extends Eth {
/**
* Verifies a confidential token transfer (SendERC7984) transaction.
*
* With txHex (multisig second-signer, MPC post-signing):
* 1. Decodes the sendMultiSig calldata and checks the inner token contract address.
* 2. Requires the decoded recipient to match txParams.recipients[0].address or
* buildParams.recipients[0].address — at least one must be present.
* 3. Confirms encryptedHandle and inputProof are structurally present.
* 4. Validates txParams.recipients[0].amount is a positive integer and matches
* buildParams.recipients[0].amount when both are present.
* With txHex — two on-chain shapes are supported:
*
* Without txHex (multisig first-signer, MPC pre-signing):
* sendMultiSig-wrapped (multisig / smart-contract wallet):
* tx.to = wallet contract
* tx.data = sendMultiSig(tokenAddr, 0, confidentialTransfer(recipient, handle, proof), ...)
* Token contract address is decoded from the sendMultiSig inner calldata.
*
* Direct call (hot / TSS EOA wallet):
* tx.to = token contract
* tx.data = confidentialTransfer(recipient, handle, proof)
* Token contract address is taken from tx.to.
*
* For both shapes the verifier checks:
* 1. Token contract address matches this coin's tokenContractAddress.
* 2. Decoded recipient matches txParams.recipients[0].address or buildParams.recipients[0].address.
* 3. encryptedHandle and inputProof are structurally present (non-empty).
* 4. txParams.recipients[0].amount is a positive integer and matches buildParams when both present.
*
* Without txHex (first-signer / pre-signing path):
* 1. Requires exactly one recipient in txParams.
* 2. Validates txParams.recipients[0].address is a valid Ethereum address.
* 3. Validates txParams.recipients[0].amount is a positive integer.
Expand Down Expand Up @@ -200,20 +213,45 @@ export class Erc7984Token extends Eth {
const tx = await txBuilder.build();
const txJson = tx.toJson();

let decoded: ReturnType<typeof decodeConfidentialTransferData>;
let toAddress: string;
let tokenContractAddress: string;
let encryptedHandle: string;
let inputProof: string;

try {
decoded = decodeConfidentialTransferData(txJson.data);
if (txJson.data.startsWith(sendMultisigMethodId)) {
// sendMultiSig-wrapped path: smart-contract wallet relays the confidentialTransfer call.
// Token contract address is encoded inside the sendMultiSig calldata.
const decoded = decodeConfidentialTransferData(txJson.data);
toAddress = decoded.toAddress;
tokenContractAddress = decoded.tokenContractAddress;
encryptedHandle = decoded.encryptedHandle;
inputProof = decoded.inputProof;
} else if (txJson.data.startsWith(confidentialTransferWithProofMethodId)) {
// Direct call path: hot/TSS EOA wallet calls the token contract directly.
// The transaction's `to` field is the token contract address.
if (!txJson.to) {
throw new Error('direct confidentialTransfer call is missing transaction to address');
}
const decoded = decodeDirectConfidentialTransferCalldata(txJson.data);
toAddress = decoded.toAddress;
tokenContractAddress = txJson.to;
encryptedHandle = decoded.encryptedHandle;
inputProof = decoded.inputProof;
} else {
throw new Error(`unexpected method ID ${txJson.data.slice(0, 10)}`);
}
} catch (e) {
throw new Error(
`verifyConfidentialTransfer: failed to decode confidential transfer calldata — ${(e as Error).message}`
);
}

// 1. Token contract address must match this coin
if (decoded.tokenContractAddress.toLowerCase() !== this.tokenContractAddress.toLowerCase()) {
if (tokenContractAddress.toLowerCase() !== this.tokenContractAddress.toLowerCase()) {
throw new Error(
`verifyConfidentialTransfer: token contract address mismatch — ` +
`expected ${this.tokenContractAddress}, got ${decoded.tokenContractAddress}`
`expected ${this.tokenContractAddress}, got ${tokenContractAddress}`
);
}

Expand All @@ -224,20 +262,19 @@ export class Erc7984Token extends Eth {
'verifyConfidentialTransfer: missing expected recipient (provide txParams.recipients or txPrebuild.buildParams.recipients)'
);
}
if (decoded.toAddress.toLowerCase() !== expectedRecipient.toLowerCase()) {
if (toAddress.toLowerCase() !== expectedRecipient.toLowerCase()) {
throw new Error(
`verifyConfidentialTransfer: recipient address mismatch — ` +
`expected ${expectedRecipient}, got ${decoded.toAddress}`
`verifyConfidentialTransfer: recipient address mismatch — ` + `expected ${expectedRecipient}, got ${toAddress}`
);
}

// 3. encryptedHandle must be a non-trivial hex value (not bare '0x')
if (!decoded.encryptedHandle || decoded.encryptedHandle === '0x') {
if (!encryptedHandle || encryptedHandle === '0x') {
throw new Error('verifyConfidentialTransfer: encryptedHandle is missing or empty in transaction calldata');
}

// 4. inputProof must be a non-trivial hex value
if (!decoded.inputProof || decoded.inputProof === '0x') {
if (!inputProof || inputProof === '0x') {
throw new Error('verifyConfidentialTransfer: inputProof is missing or empty in transaction calldata');
}

Expand Down
151 changes: 151 additions & 0 deletions modules/sdk-coin-eth/test/unit/erc7984Token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,157 @@ describe('verifyTransaction – confidential transfer (SendERC7984)', function (
});
});

// ---------------------------------------------------------------------------
// verifyTransaction – direct confidentialTransfer (hot/TSS EOA wallet) tests
// ---------------------------------------------------------------------------

/**
* Builds a raw tx hex where the EOA calls confidentialTransfer directly on the token
* contract (no sendMultiSig wrapper). This is the hot/TSS wallet path.
*/
async function buildDirectConfidentialTransferTxHex(opts: {
tokenContractAddress: string;
recipientAddress: string;
encryptedHandle: string;
inputProof: string;
}): Promise<string> {
const txBuilder = getBuilder('hteth') as TransactionBuilder;
txBuilder.fee({ fee: '1000000000', gasLimit: '200000' });
txBuilder.counter(1);
txBuilder.type(TransactionType.ContractCall);
// tx.to = token contract (EOA calls it directly)
txBuilder.contract(opts.tokenContractAddress);
// Inner calldata: confidentialTransfer(address, bytes32, bytes) — no sendMultiSig wrapper
const calldata = new TransferBuilderERC7984()
.to(opts.recipientAddress)
.tokenContractAddress(opts.tokenContractAddress)
.encryptedHandle(opts.encryptedHandle)
.inputProof(opts.inputProof)
.build();
txBuilder.data(calldata);
const tx = await txBuilder.build();
return tx.toBroadcastFormat();
}

describe('verifyTransaction – direct confidentialTransfer (hot/TSS EOA wallet)', function () {
const RECIPIENT = '0x19645032c7f1533395d44a629462e751084d3e4c';
const HANDLE = '0x' + 'ab'.repeat(32);
const PROOF = '0x' + 'cd'.repeat(50);
const WRONG_RECIPIENT = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const WRONG_TOKEN = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
const AMOUNT = '1000000';

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 direct confidentialTransfer tx', async function () {
const txHex = await buildDirectConfidentialTransferTxHex({
tokenContractAddress: CTEST1_TOKEN_ADDRESS,
recipientAddress: RECIPIENT,
encryptedHandle: HANDLE,
inputProof: PROOF,
});
const result = await coin.verifyTransaction({
txParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] },
txPrebuild: { txHex, buildParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] } } as any,
wallet: {} as any,
});
result.should.equal(true);
});

it('should verify using buildParams.recipients when txParams has no recipients', async function () {
const txHex = await buildDirectConfidentialTransferTxHex({
tokenContractAddress: CTEST1_TOKEN_ADDRESS,
recipientAddress: RECIPIENT,
encryptedHandle: HANDLE,
inputProof: PROOF,
});
const result = await coin.verifyTransaction({
txParams: {},
txPrebuild: {
txHex,
buildParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] },
} as any,
wallet: {} as any,
});
result.should.equal(true);
});

it('should throw when token contract address (tx.to) does not match this coin', async function () {
const txHex = await buildDirectConfidentialTransferTxHex({
tokenContractAddress: WRONG_TOKEN,
recipientAddress: RECIPIENT,
encryptedHandle: HANDLE,
inputProof: PROOF,
});
await coin
.verifyTransaction({
txParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] },
txPrebuild: { txHex } as any,
wallet: {} as any,
})
.should.be.rejectedWith(/token contract address mismatch/);
});

it('should throw when recipient address does not match txParams', async function () {
const txHex = await buildDirectConfidentialTransferTxHex({
tokenContractAddress: CTEST1_TOKEN_ADDRESS,
recipientAddress: RECIPIENT,
encryptedHandle: HANDLE,
inputProof: PROOF,
});
await coin
.verifyTransaction({
txParams: { recipients: [{ address: WRONG_RECIPIENT, amount: AMOUNT }] },
txPrebuild: { txHex } as any,
wallet: {} as any,
})
.should.be.rejectedWith(/recipient address mismatch/);
});

it('should throw when no recipient info is provided in either txParams or buildParams', async function () {
const txHex = await buildDirectConfidentialTransferTxHex({
tokenContractAddress: CTEST1_TOKEN_ADDRESS,
recipientAddress: RECIPIENT,
encryptedHandle: HANDLE,
inputProof: PROOF,
});
await coin
.verifyTransaction({
txParams: {},
txPrebuild: { txHex } as any,
wallet: {} as any,
})
.should.be.rejectedWith(/missing expected recipient/);
});

it('should throw when txParams amount does not match buildParams amount', async function () {
const txHex = await buildDirectConfidentialTransferTxHex({
tokenContractAddress: CTEST1_TOKEN_ADDRESS,
recipientAddress: RECIPIENT,
encryptedHandle: HANDLE,
inputProof: PROOF,
});
await coin
.verifyTransaction({
txParams: { recipients: [{ address: RECIPIENT, amount: '9999999' }] },
txPrebuild: {
txHex,
buildParams: { recipients: [{ address: RECIPIENT, amount: AMOUNT }] },
} as any,
wallet: {} as any,
})
.should.be.rejectedWith(/amount mismatch/);
});
});

// ---------------------------------------------------------------------------
// decodeTokenAddressesFromDelegationCalldata tests
// ---------------------------------------------------------------------------
Expand Down
Loading