Skip to content

Commit ee04aa2

Browse files
feat(sdk-coin-eth): add zama token withdrawal support
TICKET: CHALO-529
1 parent 13c988f commit ee04aa2

11 files changed

Lines changed: 1111 additions & 18 deletions

File tree

modules/abstract-eth/src/lib/transactionBuilder.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
import { defaultWalletVersion, walletSimpleConstructor } from './walletUtil';
4646
import { ERC1155TransferBuilder } from './transferBuilders/transferBuilderERC1155';
4747
import { ERC721TransferBuilder } from './transferBuilders/transferBuilderERC721';
48+
import { TransferBuilderERC7984 } from './transferBuilders/transferBuilderERC7984';
4849
import { Transaction } from './transaction';
4950
import { TransferBuilder } from './transferBuilder';
5051

@@ -77,7 +78,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
7778
private _tokenId: string;
7879

7980
// Send and AddressInitialization transaction specific parameters
80-
protected _transfer: TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder;
81+
protected _transfer: TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984;
8182
private _contractAddress: string;
8283
private _contractCounter: number;
8384
private _forwarderVersion: number;
@@ -143,6 +144,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
143144
case TransactionType.Send:
144145
case TransactionType.SendERC721:
145146
case TransactionType.SendERC1155:
147+
case TransactionType.SendERC7984:
146148
return this.buildSendTransaction();
147149
case TransactionType.AddressInitialization:
148150
return this.buildAddressInitializationTransaction();
@@ -270,6 +272,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
270272
case TransactionType.Send:
271273
case TransactionType.SendERC1155:
272274
case TransactionType.SendERC721:
275+
case TransactionType.SendERC7984:
273276
this.setContract(transactionJson.to);
274277
this._transfer = this.transfer(transactionJson.data, isFirstSigner);
275278
break;
@@ -406,6 +409,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
406409
case TransactionType.Send:
407410
case TransactionType.SendERC721:
408411
case TransactionType.SendERC1155:
412+
case TransactionType.SendERC7984:
409413
this.validateContractAddress();
410414
break;
411415
case TransactionType.AddressInitialization:
@@ -673,12 +677,12 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
673677
*
674678
* @param {string} data transfer data to initialize the transfer builder with, empty if none given
675679
* @param {boolean} isFirstSigner whether the transaction is being signed by the first signer
676-
* @returns {TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder} the transfer builder
680+
* @returns {TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984} the transfer builder
677681
*/
678682
abstract transfer(
679683
data?: string,
680684
isFirstSigner?: boolean
681-
): TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder;
685+
): TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984;
682686

683687
/**
684688
* Returns the serialized sendMultiSig contract method data
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './baseNFTTransferBuilder';
22
export * from './transferBuilderERC1155';
33
export * from './transferBuilderERC721';
4+
export * from './transferBuilderERC7984';
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { BuildTransactionError, InvalidParameterValueError } from '@bitgo/sdk-core';
2+
import { coins, EthereumNetwork as EthLikeNetwork } from '@bitgo/statics';
3+
4+
import { ContractCall } from '../contractCall';
5+
import { decodeConfidentialTransferData, isValidEthAddress, sendMultiSigData } from '../utils';
6+
import { BaseNFTTransferBuilder } from './baseNFTTransferBuilder';
7+
import { confidentialTransferWithProofMethodId, confidentialTransferWithProofTypes } from '../walletUtil';
8+
9+
export class TransferBuilderERC7984 extends BaseNFTTransferBuilder {
10+
private _encryptedHandle: string;
11+
private _inputProof: string;
12+
/** Plaintext token amount in base units, stored as metadata only (NOT included in calldata). */
13+
private _amount: string;
14+
15+
constructor(serializedData?: string) {
16+
super(serializedData);
17+
if (serializedData) {
18+
this.decodeTransferData(serializedData);
19+
}
20+
}
21+
22+
coin(coin: string): this {
23+
this._coin = coins.get(coin);
24+
this._nativeCoinOperationHashPrefix = (this._coin.network as EthLikeNetwork).nativeCoinOperationHashPrefix;
25+
return this;
26+
}
27+
28+
tokenContractAddress(address: string): this {
29+
if (isValidEthAddress(address)) {
30+
this._tokenContractAddress = address;
31+
return this;
32+
}
33+
throw new InvalidParameterValueError('Invalid address');
34+
}
35+
36+
/**
37+
* Set the plaintext transfer amount in base units.
38+
*
39+
* This value is stored as metadata only — it is NOT included in the on-chain
40+
* calldata, which carries only the encrypted form (encryptedHandle + inputProof).
41+
* Storing it here lets verifyTransaction confirm the signer's intent against
42+
* the original amount the client submitted.
43+
*/
44+
amount(amount: string): this {
45+
if (!/^\d+$/.test(amount) || BigInt(amount) <= 0n) {
46+
throw new InvalidParameterValueError('amount must be a positive integer string in base units');
47+
}
48+
this._amount = amount;
49+
return this;
50+
}
51+
52+
/**
53+
* Set the encrypted handle (bytes32 hex from WP)
54+
* Must be a 0x-prefixed 32-byte hex string (66 chars total)
55+
*/
56+
encryptedHandle(handle: string): this {
57+
if (!/^0x[0-9a-fA-F]{64}$/.test(handle)) {
58+
throw new InvalidParameterValueError('encryptedHandle must be a 0x-prefixed 32-byte hex string (66 characters)');
59+
}
60+
this._encryptedHandle = handle;
61+
return this;
62+
}
63+
64+
/**
65+
* Set the input proof (bytes hex from WP)
66+
* Must be a 0x-prefixed non-empty hex bytes string
67+
*/
68+
inputProof(proof: string): this {
69+
if (!/^0x[0-9a-fA-F]{2,}$/.test(proof) || proof.length < 4) {
70+
throw new InvalidParameterValueError('inputProof must be a 0x-prefixed non-empty hex bytes string');
71+
}
72+
this._inputProof = proof;
73+
return this;
74+
}
75+
76+
getIsFirstSigner(): boolean {
77+
return false;
78+
}
79+
80+
build(): string {
81+
this.validateMandatoryFields();
82+
const contractCall = new ContractCall(confidentialTransferWithProofMethodId, confidentialTransferWithProofTypes, [
83+
this._toAddress,
84+
this._encryptedHandle,
85+
this._inputProof,
86+
]);
87+
return contractCall.serialize();
88+
}
89+
90+
signAndBuild(chainId: string): string {
91+
if (!Number.isInteger(this._sequenceId)) {
92+
throw new BuildTransactionError('Missing mandatory field: contract sequence id');
93+
}
94+
this._chainId = chainId;
95+
this.validateMandatoryFields();
96+
this._data = this.build();
97+
98+
return sendMultiSigData(
99+
this._tokenContractAddress,
100+
'0',
101+
this._data,
102+
this._expirationTime,
103+
this._sequenceId,
104+
this.getSignature()
105+
);
106+
}
107+
108+
private validateMandatoryFields(): void {
109+
if (!this._toAddress) {
110+
throw new BuildTransactionError('Missing mandatory field: destination (to) address');
111+
}
112+
if (!this._tokenContractAddress) {
113+
throw new BuildTransactionError('Missing mandatory field: token contract address');
114+
}
115+
if (!this._encryptedHandle) {
116+
throw new BuildTransactionError('Missing mandatory field: encryptedHandle');
117+
}
118+
if (!this._inputProof || this._inputProof === '0x' || !/^0x[0-9a-fA-F]{2,}$/.test(this._inputProof)) {
119+
throw new BuildTransactionError('Missing mandatory field: inputProof');
120+
}
121+
}
122+
123+
private decodeTransferData(data: string): void {
124+
const transferData = decodeConfidentialTransferData(data);
125+
this._toAddress = transferData.toAddress;
126+
this._tokenContractAddress = transferData.tokenContractAddress;
127+
this._encryptedHandle = transferData.encryptedHandle;
128+
this._inputProof = transferData.inputProof;
129+
this._expirationTime = parseInt(transferData.expireTime, 10);
130+
this._sequenceId = parseInt(transferData.sequenceId, 10);
131+
this._signature = transferData.signature;
132+
}
133+
}

modules/abstract-eth/src/lib/utils.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ import {
8383
flushForwarderTokensMethodIdV4,
8484
sendMultiSigTokenTypesFirstSigner,
8585
sendMultiSigTypesFirstSigner,
86+
confidentialTransferWithProofMethodId,
87+
confidentialTransferWithProofTypes,
8688
} from './walletUtil';
8789
import { EthTransactionData } from './types';
8890
import { delegateForUserDecryptionMethodId } from './zamaUtils';
@@ -680,6 +682,59 @@ export function decodeFlushTokensData(data: string, to?: string): FlushTokensDat
680682
}
681683
}
682684

685+
export interface ConfidentialTransferData {
686+
toAddress: string;
687+
tokenContractAddress: string;
688+
encryptedHandle: string;
689+
inputProof: string;
690+
expireTime: string;
691+
sequenceId: string;
692+
signature: string;
693+
}
694+
695+
/**
696+
* Decode ABI-encoded confidential transfer data (sendMultiSig wrapping confidentialTransfer)
697+
*
698+
* @param data The full calldata hex string
699+
* @returns parsed confidential transfer fields
700+
*/
701+
export function decodeConfidentialTransferData(data: string): ConfidentialTransferData {
702+
if (!data.startsWith(sendMultisigMethodId)) {
703+
// Include only the 4-byte method ID in the error to avoid leaking encrypted payloads into logs.
704+
throw new BuildTransactionError(
705+
`Invalid confidential transfer bytecode: unexpected method ID ${data.slice(0, 10)}`
706+
);
707+
}
708+
709+
const [tokenContractAddress, , internalData, expireTime, sequenceId, signature] = getRawDecoded(
710+
sendMultiSigTypes,
711+
getBufferedByteCode(sendMultisigMethodId, data)
712+
);
713+
714+
const internalDataHex = bufferToHex(internalData as Buffer);
715+
if (!internalDataHex.startsWith(confidentialTransferWithProofMethodId)) {
716+
// Include only the 4-byte method ID in the error to avoid leaking encrypted payloads into logs.
717+
throw new BuildTransactionError(
718+
`Invalid confidential transfer inner calldata: unexpected method ID ${internalDataHex.slice(0, 10)}`
719+
);
720+
}
721+
722+
const [toAddress, encryptedHandle, inputProof] = getRawDecoded(
723+
confidentialTransferWithProofTypes,
724+
getBufferedByteCode(confidentialTransferWithProofMethodId, internalDataHex)
725+
);
726+
727+
return {
728+
toAddress: addHexPrefix(toAddress as string),
729+
tokenContractAddress: addHexPrefix(tokenContractAddress as string),
730+
encryptedHandle: bufferToHex(encryptedHandle as Buffer),
731+
inputProof: bufferToHex(inputProof as Buffer),
732+
expireTime: bufferToInt(expireTime as Buffer).toString(),
733+
sequenceId: bufferToInt(sequenceId as Buffer).toString(),
734+
signature: bufferToHex(signature as Buffer),
735+
};
736+
}
737+
683738
/**
684739
* Classify the given transaction data based as a transaction type.
685740
* ETH transactions are defined by the first 8 bytes of the transaction data, also known as the method id
@@ -696,6 +751,20 @@ export function classifyTransaction(data: string): TransactionType {
696751

697752
// TODO(STLX-1970): validate if we are going to constraint to some methods allowed
698753
let transactionType = transactionTypesMap[data.slice(0, 10).toLowerCase()];
754+
755+
// For sendMultiSig transactions, peek at the inner calldata to detect SendERC7984
756+
if (transactionType === TransactionType.Send && data.startsWith(sendMultisigMethodId)) {
757+
try {
758+
const [, , internalData] = getRawDecoded(sendMultiSigTypes, getBufferedByteCode(sendMultisigMethodId, data));
759+
const internalDataHex = bufferToHex(internalData as Buffer);
760+
if (internalDataHex.startsWith(confidentialTransferWithProofMethodId)) {
761+
return TransactionType.SendERC7984;
762+
}
763+
} catch {
764+
// Not a confidential transfer; fall through to normal classification
765+
}
766+
}
767+
699768
if (transactionType === undefined) {
700769
transactionType = TransactionType.ContractCall;
701770
}

modules/abstract-eth/src/lib/walletUtil.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,10 @@ export const ERC1155SafeTransferTypes = ['address', 'address', 'uint256', 'uint2
4545
export const ERC1155BatchTransferTypes = ['address', 'address', 'uint256[]', 'uint256[]', 'bytes'];
4646
export const createV1ForwarderTypes = ['address', 'bytes32'];
4747
export const createV4ForwarderTypes = ['address', 'address', 'bytes32'];
48+
49+
// keccak256("confidentialTransfer(address,bytes32,bytes)")[0:4]
50+
export const confidentialTransferWithProofMethodId = '0x2fb74e62';
51+
// keccak256("confidentialTransfer(address,bytes32)")[0:4] — for decoding only
52+
export const confidentialTransferNoProofMethodId = '0x5bebed7e';
53+
// ABI parameter types for the 3-param version
54+
export const confidentialTransferWithProofTypes = ['address', 'bytes32', 'bytes'];

0 commit comments

Comments
 (0)