Skip to content
Draft
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
10 changes: 7 additions & 3 deletions modules/abstract-eth/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
import { defaultWalletVersion, walletSimpleConstructor } from './walletUtil';
import { ERC1155TransferBuilder } from './transferBuilders/transferBuilderERC1155';
import { ERC721TransferBuilder } from './transferBuilders/transferBuilderERC721';
import { TransferBuilderERC7984 } from './transferBuilders/transferBuilderERC7984';
import { Transaction } from './transaction';
import { TransferBuilder } from './transferBuilder';

Expand Down Expand Up @@ -77,7 +78,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
private _tokenId: string;

// Send and AddressInitialization transaction specific parameters
protected _transfer: TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder;
protected _transfer: TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984;
private _contractAddress: string;
private _contractCounter: number;
private _forwarderVersion: number;
Expand Down Expand Up @@ -143,6 +144,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
case TransactionType.Send:
case TransactionType.SendERC721:
case TransactionType.SendERC1155:
case TransactionType.SendERC7984:
return this.buildSendTransaction();
case TransactionType.AddressInitialization:
return this.buildAddressInitializationTransaction();
Expand Down Expand Up @@ -270,6 +272,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
case TransactionType.Send:
case TransactionType.SendERC1155:
case TransactionType.SendERC721:
case TransactionType.SendERC7984:
this.setContract(transactionJson.to);
this._transfer = this.transfer(transactionJson.data, isFirstSigner);
break;
Expand Down Expand Up @@ -406,6 +409,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
case TransactionType.Send:
case TransactionType.SendERC721:
case TransactionType.SendERC1155:
case TransactionType.SendERC7984:
this.validateContractAddress();
break;
case TransactionType.AddressInitialization:
Expand Down Expand Up @@ -673,12 +677,12 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
*
* @param {string} data transfer data to initialize the transfer builder with, empty if none given
* @param {boolean} isFirstSigner whether the transaction is being signed by the first signer
* @returns {TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder} the transfer builder
* @returns {TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984} the transfer builder
*/
abstract transfer(
data?: string,
isFirstSigner?: boolean
): TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder;
): TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984;

/**
* Returns the serialized sendMultiSig contract method data
Expand Down
1 change: 1 addition & 0 deletions modules/abstract-eth/src/lib/transferBuilders/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './baseNFTTransferBuilder';
export * from './transferBuilderERC1155';
export * from './transferBuilderERC721';
export * from './transferBuilderERC7984';
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { BuildTransactionError, InvalidParameterValueError } from '@bitgo/sdk-core';
import { coins, EthereumNetwork as EthLikeNetwork } from '@bitgo/statics';

import { ContractCall } from '../contractCall';
import { decodeConfidentialTransferData, isValidEthAddress, sendMultiSigData } from '../utils';
import { BaseNFTTransferBuilder } from './baseNFTTransferBuilder';
import { confidentialTransferWithProofMethodId, confidentialTransferWithProofTypes } from '../walletUtil';

export class TransferBuilderERC7984 extends BaseNFTTransferBuilder {
private _encryptedHandle: string;
private _inputProof: string;
/** Plaintext token amount in base units, stored as metadata only (NOT included in calldata). */
private _amount: string;

constructor(serializedData?: string) {
super(serializedData);
if (serializedData) {
this.decodeTransferData(serializedData);
}
}

coin(coin: string): this {
this._coin = coins.get(coin);
this._nativeCoinOperationHashPrefix = (this._coin.network as EthLikeNetwork).nativeCoinOperationHashPrefix;
return this;
}

tokenContractAddress(address: string): this {
if (isValidEthAddress(address)) {
this._tokenContractAddress = address;
return this;
}
throw new InvalidParameterValueError('Invalid address');
}

/**
* Set the plaintext transfer amount in base units.
*
* This value is stored as metadata only — it is NOT included in the on-chain
* calldata, which carries only the encrypted form (encryptedHandle + inputProof).
* Storing it here lets verifyTransaction confirm the signer's intent against
* the original amount the client submitted.
*/
Comment thread
MohammedRyaan786 marked this conversation as resolved.
amount(amount: string): this {
if (!/^\d+$/.test(amount) || BigInt(amount) <= 0n) {
throw new InvalidParameterValueError('amount must be a positive integer string in base units');
}
this._amount = amount;
return this;
}

/**
* Set the encrypted handle (bytes32 hex from WP)
* Must be a 0x-prefixed 32-byte hex string (66 chars total)
*/
encryptedHandle(handle: string): this {
if (!/^0x[0-9a-fA-F]{64}$/.test(handle)) {
throw new InvalidParameterValueError('encryptedHandle must be a 0x-prefixed 32-byte hex string (66 characters)');
}
this._encryptedHandle = handle;
return this;
}

/**
* Set the input proof (bytes hex from WP)
* Must be a 0x-prefixed non-empty hex bytes string
*/
inputProof(proof: string): this {
if (!/^0x[0-9a-fA-F]{2,}$/.test(proof)) {
throw new InvalidParameterValueError('inputProof must be a 0x-prefixed non-empty hex bytes string');
}
this._inputProof = proof;
Comment thread
MohammedRyaan786 marked this conversation as resolved.
return this;
}

getIsFirstSigner(): boolean {
return false;
}

build(): string {
this.validateMandatoryFields();
const contractCall = new ContractCall(confidentialTransferWithProofMethodId, confidentialTransferWithProofTypes, [
this._toAddress,
this._encryptedHandle,
this._inputProof,
]);
return contractCall.serialize();
}

signAndBuild(chainId: string): string {
if (!Number.isInteger(this._sequenceId)) {
throw new BuildTransactionError('Missing mandatory field: contract sequence id');
}
this._chainId = chainId;
this.validateMandatoryFields();
this._data = this.build();

return sendMultiSigData(
this._tokenContractAddress,
'0',
this._data,
this._expirationTime,
this._sequenceId,
this.getSignature()
);
}

private validateMandatoryFields(): void {
if (!this._toAddress) {
throw new BuildTransactionError('Missing mandatory field: destination (to) address');
}
if (!this._tokenContractAddress) {
throw new BuildTransactionError('Missing mandatory field: token contract address');
}
if (!this._encryptedHandle) {
throw new BuildTransactionError('Missing mandatory field: encryptedHandle');
}
if (!this._inputProof || this._inputProof === '0x' || !/^0x[0-9a-fA-F]{2,}$/.test(this._inputProof)) {
throw new BuildTransactionError('Missing mandatory field: inputProof');
}
Comment thread
MohammedRyaan786 marked this conversation as resolved.
}
Comment thread
MohammedRyaan786 marked this conversation as resolved.

private decodeTransferData(data: string): void {
const transferData = decodeConfidentialTransferData(data);
this._toAddress = transferData.toAddress;
this._tokenContractAddress = transferData.tokenContractAddress;
this._encryptedHandle = transferData.encryptedHandle;
this._inputProof = transferData.inputProof;
this._expirationTime = parseInt(transferData.expireTime, 10);
this._sequenceId = parseInt(transferData.sequenceId, 10);
this._signature = transferData.signature;
}
}
69 changes: 69 additions & 0 deletions modules/abstract-eth/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ import {
flushForwarderTokensMethodIdV4,
sendMultiSigTokenTypesFirstSigner,
sendMultiSigTypesFirstSigner,
confidentialTransferWithProofMethodId,
confidentialTransferWithProofTypes,
} from './walletUtil';
import { EthTransactionData } from './types';
import { delegateForUserDecryptionMethodId } from './zamaUtils';
Expand Down Expand Up @@ -680,6 +682,59 @@ export function decodeFlushTokensData(data: string, to?: string): FlushTokensDat
}
}

export interface ConfidentialTransferData {
toAddress: string;
tokenContractAddress: string;
encryptedHandle: string;
inputProof: string;
expireTime: string;
sequenceId: string;
signature: string;
}

/**
* Decode ABI-encoded confidential transfer data (sendMultiSig wrapping confidentialTransfer)
*
* @param data The full calldata hex string
* @returns parsed confidential transfer fields
*/
export function decodeConfidentialTransferData(data: string): ConfidentialTransferData {
if (!data.startsWith(sendMultisigMethodId)) {
// Include only the 4-byte method ID in the error to avoid leaking encrypted payloads into logs.
throw new BuildTransactionError(
`Invalid confidential transfer bytecode: unexpected method ID ${data.slice(0, 10)}`
);
}

const [tokenContractAddress, , internalData, expireTime, sequenceId, signature] = getRawDecoded(
sendMultiSigTypes,
getBufferedByteCode(sendMultisigMethodId, data)
);

const internalDataHex = bufferToHex(internalData as Buffer);
if (!internalDataHex.startsWith(confidentialTransferWithProofMethodId)) {
// Include only the 4-byte method ID in the error to avoid leaking encrypted payloads into logs.
throw new BuildTransactionError(
`Invalid confidential transfer inner calldata: unexpected method ID ${internalDataHex.slice(0, 10)}`
);
}
Comment thread
MohammedRyaan786 marked this conversation as resolved.

const [toAddress, encryptedHandle, inputProof] = getRawDecoded(
confidentialTransferWithProofTypes,
getBufferedByteCode(confidentialTransferWithProofMethodId, internalDataHex)
);

return {
toAddress: addHexPrefix(toAddress as string),
tokenContractAddress: addHexPrefix(tokenContractAddress as string),
encryptedHandle: bufferToHex(encryptedHandle as Buffer),
inputProof: bufferToHex(inputProof as Buffer),
expireTime: bufferToInt(expireTime as Buffer).toString(),
sequenceId: bufferToInt(sequenceId as Buffer).toString(),
signature: bufferToHex(signature as Buffer),
};
}

/**
* Classify the given transaction data based as a transaction type.
* ETH transactions are defined by the first 8 bytes of the transaction data, also known as the method id
Expand All @@ -696,6 +751,20 @@ export function classifyTransaction(data: string): TransactionType {

// TODO(STLX-1970): validate if we are going to constraint to some methods allowed
let transactionType = transactionTypesMap[data.slice(0, 10).toLowerCase()];

// For sendMultiSig transactions, peek at the inner calldata to detect SendERC7984
if (transactionType === TransactionType.Send && data.startsWith(sendMultisigMethodId)) {
try {
const [, , internalData] = getRawDecoded(sendMultiSigTypes, getBufferedByteCode(sendMultisigMethodId, data));
const internalDataHex = bufferToHex(internalData as Buffer);
if (internalDataHex.startsWith(confidentialTransferWithProofMethodId)) {
return TransactionType.SendERC7984;
}
} catch {
// Not a confidential transfer; fall through to normal classification
}
}

if (transactionType === undefined) {
transactionType = TransactionType.ContractCall;
}
Expand Down
7 changes: 7 additions & 0 deletions modules/abstract-eth/src/lib/walletUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,10 @@ export const ERC1155SafeTransferTypes = ['address', 'address', 'uint256', 'uint2
export const ERC1155BatchTransferTypes = ['address', 'address', 'uint256[]', 'uint256[]', 'bytes'];
export const createV1ForwarderTypes = ['address', 'bytes32'];
export const createV4ForwarderTypes = ['address', 'address', 'bytes32'];

// keccak256("confidentialTransfer(address,bytes32,bytes)")[0:4]
export const confidentialTransferWithProofMethodId = '0x2fb74e62';
// keccak256("confidentialTransfer(address,bytes32)")[0:4] — for decoding only
export const confidentialTransferNoProofMethodId = '0x5bebed7e';
// ABI parameter types for the 3-param version
export const confidentialTransferWithProofTypes = ['address', 'bytes32', 'bytes'];
Loading
Loading