From 15dbe7a57d78c54b007454a82bc54ec01925e414 Mon Sep 17 00:00:00 2001 From: Prabhsharan Singh Date: Thu, 13 Aug 2026 18:45:35 +0530 Subject: [PATCH] feat(abstract-eth): add buildWrapCalldata and WrapERC7984 explain/verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encode wrap(to, amount) for ERC-7984 shield (selector 0xbf376c7a), add WrapERC7984 tx type with builder classification, and verify self-directed wrap calldata including amount × rate uint64 checks. Ticket: CHALO-1154 Co-authored-by: Cursor --- modules/abstract-eth/src/lib/iface.ts | 9 + .../src/lib/transactionBuilder.ts | 82 ++++++++- modules/abstract-eth/src/lib/utils.ts | 19 +++ modules/abstract-eth/src/lib/zamaUtils.ts | 108 ++++++++++++ .../test/unit/transactionBuilder/index.ts | 1 + .../unit/transactionBuilder/wrapERC7984.ts | 155 ++++++++++++++++++ modules/abstract-eth/test/unit/zamaUtils.ts | 96 ++++++++++- modules/sdk-coin-eth/src/erc7984Token.ts | 106 ++++++++++++ .../sdk-coin-eth/test/unit/erc7984Token.ts | 119 ++++++++++++++ .../unit/transactionBuilder/flushTokens.ts | 14 +- .../sdk-core/src/account-lib/baseCoin/enum.ts | 2 + 11 files changed, 706 insertions(+), 5 deletions(-) create mode 100644 modules/abstract-eth/test/unit/transactionBuilder/wrapERC7984.ts diff --git a/modules/abstract-eth/src/lib/iface.ts b/modules/abstract-eth/src/lib/iface.ts index 02f92e61e9..e54097767c 100644 --- a/modules/abstract-eth/src/lib/iface.ts +++ b/modules/abstract-eth/src/lib/iface.ts @@ -166,3 +166,12 @@ export interface FlushERC7984ForwarderTokenData { encryptedHandle: string; // bytes32 hex parentAddress: string; } + +export interface WrapERC7984Data { + /** Confidential wrapper contract (tx.to) */ + wrapperAddress: string; + /** Recipient of minted confidential balance (wrap `to` arg) */ + to: string; + /** Underlying amount wrapped (base units, decimal string) */ + amount: string; +} diff --git a/modules/abstract-eth/src/lib/transactionBuilder.ts b/modules/abstract-eth/src/lib/transactionBuilder.ts index 72fcd52e50..1fae998123 100644 --- a/modules/abstract-eth/src/lib/transactionBuilder.ts +++ b/modules/abstract-eth/src/lib/transactionBuilder.ts @@ -20,7 +20,14 @@ import { } from '@bitgo/sdk-core'; import { KeyPair } from './keyPair'; -import { ETHTransactionType, Fee, FlushERC7984ForwarderTokenData, SignatureParts, TxData } from './iface'; +import { + ETHTransactionType, + Fee, + FlushERC7984ForwarderTokenData, + SignatureParts, + TxData, + WrapERC7984Data, +} from './iface'; import { calculateForwarderAddress, calculateForwarderV1Address, @@ -30,6 +37,7 @@ import { decodeFlushERC721TokensData, decodeFlushERC1155TokensData, decodeFlushERC7984ForwarderTokenData, + decodeWrapERC7984Data, decodeWalletCreationData, flushCoinsData, flushTokensData, @@ -43,7 +51,7 @@ import { getV1WalletInitializationData, getCreateForwarderParamsAndTypes, } from './utils'; -import { buildFlushERC7984ForwarderTokenCalldata } from './zamaUtils'; +import { buildFlushERC7984ForwarderTokenCalldata, buildWrapCalldata } from './zamaUtils'; import { defaultWalletVersion, walletSimpleConstructor } from './walletUtil'; import { ERC1155TransferBuilder } from './transferBuilders/transferBuilderERC1155'; import { ERC721TransferBuilder } from './transferBuilders/transferBuilderERC721'; @@ -84,6 +92,11 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { private _encryptedHandle: string; // bytes32 hex from confidentialBalanceOf private _parentAddress: string; // where flushed tokens go (wallet base address) + // WrapERC7984 parameters + private _wrapRecipient: string; // wrap(to, ...) recipient of confidential mint + private _wrapAmount: string; // underlying amount to wrap (base units) + private _wrapRate: string; // on-chain rate() for uint64 validation + // Send and AddressInitialization transaction specific parameters protected _transfer: TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984; private _contractAddress: string; @@ -170,6 +183,8 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { return this.buildGenericContractCallTransaction(); case TransactionType.FlushERC7984ForwarderToken: return this.buildFlushERC7984ForwarderTokenTransaction(); + case TransactionType.WrapERC7984: + return this.buildWrapERC7984Transaction(); default: throw new BuildTransactionError('Unsupported transaction type'); } @@ -324,6 +339,14 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this.parentAddress(erc7984Data.parentAddress); break; } + case TransactionType.WrapERC7984: { + this.setContract(transactionJson.to); + const wrapData: WrapERC7984Data = decodeWrapERC7984Data(transactionJson.data, transactionJson.to!); + this.wrapRecipient(wrapData.to); + this.wrapAmount(wrapData.amount); + // rate is not encoded in calldata; callers that rebuild must set wrapRate() again + break; + } default: throw new BuildTransactionError('Unsupported transaction type'); // TODO: Add other cases of deserialization @@ -482,6 +505,11 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this.validateEncryptedHandle(); this.validateParentAddress(); break; + case TransactionType.WrapERC7984: + this.validateContractAddress(); + this.validateWrapRecipient(); + this.validateWrapAmount(); + break; default: throw new BuildTransactionError('Unsupported transaction type'); } @@ -565,6 +593,18 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } } + private validateWrapRecipient(): void { + if (!this._wrapRecipient) { + throw new BuildTransactionError('Invalid transaction: missing wrapRecipient'); + } + } + + private validateWrapAmount(): void { + if (!this._wrapAmount) { + throw new BuildTransactionError('Invalid transaction: missing wrapAmount'); + } + } + private setContract(address: string | undefined): void { if (address === undefined) { throw new BuildTransactionError('Undefined recipient address'); @@ -1094,4 +1134,42 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } // endregion + + // region WrapERC7984 builder methods + + /** + * Set the wrap recipient (`to` argument of wrap(address,uint256)). + * Self-directed shields use the wallet base address. + */ + wrapRecipient(address: string): void { + if (!isValidEthAddress(address)) { + throw new BuildTransactionError('Invalid address: ' + address); + } + this._wrapRecipient = address; + } + + /** + * Set the underlying amount to wrap (base units, decimal string). + */ + wrapAmount(amount: string): void { + this._wrapAmount = amount; + } + + /** + * Set the wrapper on-chain `rate()` used for amount × rate uint64 validation. + */ + wrapRate(rate: string): void { + this._wrapRate = rate; + } + + /** + * Build a WrapERC7984 (shield) transaction: wrap(to, amount) against the wrapper contract. + * Does not set gasLimit — WP owns gas. + */ + private buildWrapERC7984Transaction(): TxData { + const data = buildWrapCalldata(this._wrapRecipient, this._wrapAmount, this._wrapRate); + return this.buildBase(data); + } + + // endregion } diff --git a/modules/abstract-eth/src/lib/utils.ts b/modules/abstract-eth/src/lib/utils.ts index 208389ff4a..73b888c230 100644 --- a/modules/abstract-eth/src/lib/utils.ts +++ b/modules/abstract-eth/src/lib/utils.ts @@ -42,6 +42,7 @@ import { TxData, WalletInitializationData, ForwarderInitializationData, + WrapERC7984Data, } from './iface'; import { KeyPair } from './keyPair'; import { @@ -93,7 +94,9 @@ import { callFromParentMethodId, callFromParentTypes, decodeFlushERC7984ForwarderTokenCalldata, + decodeWrapCalldata, delegateForUserDecryptionMethodId, + wrapMethodId, } from './zamaUtils'; /** @@ -830,6 +833,21 @@ export function decodeFlushERC7984ForwarderTokenData(data: string, to: string): }; } +/** + * Decode a WrapERC7984 transaction's calldata into its component parts. + * + * @param data The wrap(address,uint256) calldata hex + * @param to The transaction `to` field (wrapper contract address) + */ +export function decodeWrapERC7984Data(data: string, to: string): WrapERC7984Data { + const { to: recipient, amount } = decodeWrapCalldata(data); + return { + wrapperAddress: to, + to: recipient, + amount, + }; +} + /** * 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 @@ -915,6 +933,7 @@ const transactionTypesMap = { // classifies as ContractCall; callers should set TransactionType.DecryptionDelegation // explicitly when building from a known delegation template. [delegateForUserDecryptionMethodId]: TransactionType.DecryptionDelegation, + [wrapMethodId]: TransactionType.WrapERC7984, }; /** diff --git a/modules/abstract-eth/src/lib/zamaUtils.ts b/modules/abstract-eth/src/lib/zamaUtils.ts index 0010ad0fea..31a23bb2e3 100644 --- a/modules/abstract-eth/src/lib/zamaUtils.ts +++ b/modules/abstract-eth/src/lib/zamaUtils.ts @@ -12,6 +12,10 @@ export const delegateForUserDecryptionTypes = ['address', 'address', 'uint64'] a export const callFromParentTypes = ['address', 'uint256', 'bytes'] as const; export const aclMulticallTypes = ['bytes[]'] as const; export const approveTypes = ['address', 'uint256'] as const; +export const wrapTypes = ['address', 'uint256'] as const; + +/** Max value for Solidity `uint64` / ERC-7984 confidential amount domain (`euint64`). */ +export const UINT64_MAX = 18446744073709551615n; /** * Function selector for ACL.delegateForUserDecryption(address,address,uint64) @@ -45,10 +49,49 @@ export const callFromParentMethodId = addHexPrefix( */ export const approveMethodId = addHexPrefix(EthereumAbi.methodID('approve', [...approveTypes]).toString('hex')); +/** + * Function selector for ERC-7984 wrap(address,uint256) + * = keccak256('wrap(address,uint256)')[0:4] + * Locks underlying ERC-20 in the wrapper and mints an encrypted balance to `to`. + */ +export const wrapMethodId = addHexPrefix(EthereumAbi.methodID('wrap', [...wrapTypes]).toString('hex')); + // --------------------------------------------------------------------------- // Encoding functions // --------------------------------------------------------------------------- +/** + * Asserts that `amount * rate` fits in uint64 (ERC-7984 confidential mint domain). + * + * @throws {Error} if amount or rate is invalid, or the product exceeds uint64 + */ +export function assertAmountTimesRateFitsUint64( + amount: string | number | bigint, + rate: string | number | bigint +): void { + let amountBn: bigint; + let rateBn: bigint; + try { + amountBn = typeof amount === 'bigint' ? amount : BigInt(amount); + } catch { + throw new Error(`assertAmountTimesRateFitsUint64: invalid amount '${amount}'`); + } + try { + rateBn = typeof rate === 'bigint' ? rate : BigInt(rate); + } catch { + throw new Error(`assertAmountTimesRateFitsUint64: invalid rate '${rate}'`); + } + if (amountBn <= 0n) { + throw new Error('assertAmountTimesRateFitsUint64: amount must be > 0'); + } + if (rateBn <= 0n) { + throw new Error('assertAmountTimesRateFitsUint64: rate must be > 0'); + } + if (amountBn * rateBn > UINT64_MAX) { + throw new Error('assertAmountTimesRateFitsUint64: amount × rate exceeds uint64'); + } +} + /** * Encodes ERC-20 `approve(spender, amount)` calldata for ERC-7984 shield. * @@ -87,6 +130,71 @@ export function buildApproveCalldata(wrapperAddress: string, amount: string | nu return addHexPrefix(Buffer.concat([method, args]).toString('hex')); } +/** + * Encodes ERC-7984 `wrap(to, amount)` calldata for the shield path. + * + * Calldata is sent to the confidential wrapper contract. `to` is the recipient of + * the minted encrypted balance (self-directed shields use the wallet base address). + * When `rate` is provided, validates that `amount × rate` fits uint64 (`euint64`). + * `rate` is not encoded in calldata (deserialize round-trips omit it). + * + * Does not set gasLimit — WP owns gas. + * + * @param to Recipient of confidential tokens (checksummed / lowercased accepted) + * @param amount Underlying ERC-20 amount to wrap (base units); must be > 0 + * @param rate Optional on-chain wrapper `rate()` for uint64 validation + * @returns ABI-encoded calldata hex string (0x-prefixed) + * @throws {Error} if address/amount/rate is invalid or amount × rate exceeds uint64 + */ +export function buildWrapCalldata( + to: string, + amount: string | number | bigint, + rate?: string | number | bigint +): string { + let checksummedTo: string; + try { + checksummedTo = ethers.utils.getAddress(to); + } catch { + throw new Error(`buildWrapCalldata: invalid to address '${to}'`); + } + + let amountBn: bigint; + try { + amountBn = typeof amount === 'bigint' ? amount : BigInt(amount); + } catch { + throw new Error(`buildWrapCalldata: invalid amount '${amount}'`); + } + if (amountBn <= 0n) { + throw new Error('buildWrapCalldata: amount must be > 0'); + } + + if (rate !== undefined) { + assertAmountTimesRateFitsUint64(amountBn, rate); + } + + const method = EthereumAbi.methodID('wrap', [...wrapTypes]); + const args = EthereumAbi.rawEncode([...wrapTypes], [checksummedTo, amountBn.toString()]); + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); +} + +/** + * Decodes ERC-7984 `wrap(to, amount)` calldata. + * + * @param data ABI-encoded wrap calldata (0x-prefixed) + * @returns `{ to, amount }` with checksummed `to` and decimal-string `amount` + */ +export function decodeWrapCalldata(data: string): { to: string; amount: string } { + if (!data.toLowerCase().startsWith(wrapMethodId.toLowerCase())) { + throw new Error(`decodeWrapCalldata: expected wrap selector ${wrapMethodId}, got ${data.slice(0, 10)}`); + } + const abiCoder = new ethers.utils.AbiCoder(); + const decoded = abiCoder.decode([...wrapTypes], '0x' + data.slice(10)); + return { + to: ethers.utils.getAddress(decoded[0]), + amount: decoded[1].toString(), + }; +} + /** * Encodes a single ACL.delegateForUserDecryption() call. * diff --git a/modules/abstract-eth/test/unit/transactionBuilder/index.ts b/modules/abstract-eth/test/unit/transactionBuilder/index.ts index ebeed11045..74ec0719d3 100644 --- a/modules/abstract-eth/test/unit/transactionBuilder/index.ts +++ b/modules/abstract-eth/test/unit/transactionBuilder/index.ts @@ -4,3 +4,4 @@ export * from './walletInitialization'; export * from './flushNft'; export * from './decryptionDelegation'; export * from './flushERC7984'; +export * from './wrapERC7984'; diff --git a/modules/abstract-eth/test/unit/transactionBuilder/wrapERC7984.ts b/modules/abstract-eth/test/unit/transactionBuilder/wrapERC7984.ts new file mode 100644 index 0000000000..153d5b9c17 --- /dev/null +++ b/modules/abstract-eth/test/unit/transactionBuilder/wrapERC7984.ts @@ -0,0 +1,155 @@ +/** + * TransactionBuilder tests for WrapERC7984 transaction type. + * + * Verifies: + * - Building a WrapERC7984 tx from scratch (legacy and EIP-1559 fees) + * - Signing and serialization round-trip + * - classifyTransaction correctly identifies wrap(address,uint256) + * - Error cases for missing fields / invalid amount + */ +import { TransactionType } from '@bitgo/sdk-core'; +import should from 'should'; +import { ETHTransactionType, TransactionBuilder } from '../../../src'; +import { buildWrapCalldata, decodeWrapCalldata, wrapMethodId } from '../../../src/lib/zamaUtils'; +import { classifyTransaction } from '../../../src/lib/utils'; + +const WRAPPER_ADDRESS = '0x2debbe0487ef921df4457f9e36ed05be2df1ac75'; // hteth:cusdt +const RECIPIENT = '0x1111111111111111111111111111111111111111'; +const AMOUNT = '1000000'; +const RATE = '1'; +const TEST_PRV_KEY = 'FAC4D04AA0025ECF200D74BC9B5E4616E4B8338B69B61362AAAD49F76E68EF28'; + +export function runWrapERC7984Tests(coinName: string, getBuilder: (coin: string) => TransactionBuilder): void { + describe(`${coinName} transaction builder — WrapERC7984`, () => { + let txBuilder: TransactionBuilder; + + beforeEach(() => { + txBuilder = getBuilder(coinName); + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + }); + + describe('classifyTransaction', () => { + it('should classify wrap(address,uint256) as WrapERC7984', () => { + const calldata = buildWrapCalldata(RECIPIENT, AMOUNT, RATE); + should.equal(classifyTransaction(calldata), TransactionType.WrapERC7984); + }); + + it('should NOT classify approve as WrapERC7984', () => { + should.equal(classifyTransaction('0x095ea7b3' + '00'.repeat(64)), TransactionType.ContractCall); + }); + }); + + describe('build from scratch', () => { + it('should build a WrapERC7984 transaction', async () => { + txBuilder.type(TransactionType.WrapERC7984); + txBuilder.contract(WRAPPER_ADDRESS); + txBuilder.wrapRecipient(RECIPIENT); + txBuilder.wrapAmount(AMOUNT); + txBuilder.wrapRate(RATE); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.WrapERC7984); + json.to.toLowerCase().should.equal(WRAPPER_ADDRESS.toLowerCase()); + json.data.should.startWith(wrapMethodId); + json.value.should.equal('0'); + + const decoded = decodeWrapCalldata(json.data); + decoded.to.toLowerCase().should.equal(RECIPIENT.toLowerCase()); + decoded.amount.should.equal(AMOUNT); + }); + + it('should build with EIP-1559 fee model', async () => { + const builder = getBuilder(coinName); + builder.fee({ + fee: '30000000000', + eip1559: { + maxFeePerGas: '30000000000', + maxPriorityFeePerGas: '1000000000', + }, + gasLimit: '200000', + }); + builder.counter(1); + builder.type(TransactionType.WrapERC7984); + builder.contract(WRAPPER_ADDRESS); + builder.wrapRecipient(RECIPIENT); + builder.wrapAmount(AMOUNT); + builder.wrapRate(RATE); + + const tx = await builder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.WrapERC7984); + json._type.should.equal(ETHTransactionType.EIP1559); + json.data.should.startWith(wrapMethodId); + }); + }); + + describe('signing and round-trip', () => { + it('should produce a signed transaction with v, r, s and from fields', async () => { + txBuilder.type(TransactionType.WrapERC7984); + txBuilder.contract(WRAPPER_ADDRESS); + txBuilder.wrapRecipient(RECIPIENT); + txBuilder.wrapAmount(AMOUNT); + txBuilder.wrapRate(RATE); + txBuilder.sign({ key: TEST_PRV_KEY }); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.exist(json.v); + should.exist(json.r); + should.exist(json.s); + should.exist(json.from); + should.equal(tx.type, TransactionType.WrapERC7984); + }); + + it('should serialize and deserialize to the same transaction', async () => { + txBuilder.type(TransactionType.WrapERC7984); + txBuilder.contract(WRAPPER_ADDRESS); + txBuilder.wrapRecipient(RECIPIENT); + txBuilder.wrapAmount(AMOUNT); + txBuilder.wrapRate(RATE); + + const originalTx = await txBuilder.build(); + const rawHex = originalTx.toBroadcastFormat(); + + const rebuiltBuilder = getBuilder(coinName); + rebuiltBuilder.from(rawHex); + const rebuiltTx = await rebuiltBuilder.build(); + + rebuiltTx.toBroadcastFormat().should.equal(rawHex); + should.equal(rebuiltTx.type, TransactionType.WrapERC7984); + }); + }); + + describe('validation', () => { + it('should reject missing wrapRecipient', async () => { + txBuilder.type(TransactionType.WrapERC7984); + txBuilder.contract(WRAPPER_ADDRESS); + txBuilder.wrapAmount(AMOUNT); + txBuilder.wrapRate(RATE); + await txBuilder.build().should.be.rejectedWith(/missing wrapRecipient/); + }); + + it('should reject missing wrapAmount', async () => { + txBuilder.type(TransactionType.WrapERC7984); + txBuilder.contract(WRAPPER_ADDRESS); + txBuilder.wrapRecipient(RECIPIENT); + txBuilder.wrapRate(RATE); + await txBuilder.build().should.be.rejectedWith(/missing wrapAmount/); + }); + + it('should reject amount 0 via buildWrapCalldata', async () => { + txBuilder.type(TransactionType.WrapERC7984); + txBuilder.contract(WRAPPER_ADDRESS); + txBuilder.wrapRecipient(RECIPIENT); + txBuilder.wrapAmount('0'); + txBuilder.wrapRate(RATE); + await txBuilder.build().should.be.rejectedWith(/amount must be > 0/); + }); + }); + }); +} diff --git a/modules/abstract-eth/test/unit/zamaUtils.ts b/modules/abstract-eth/test/unit/zamaUtils.ts index e242cb08d5..5423c88b8a 100644 --- a/modules/abstract-eth/test/unit/zamaUtils.ts +++ b/modules/abstract-eth/test/unit/zamaUtils.ts @@ -2,6 +2,8 @@ import should from 'should'; import EthereumAbi from 'ethereumjs-abi'; import { buildApproveCalldata, + buildWrapCalldata, + decodeWrapCalldata, buildDelegationCalldata, buildMulticallDelegationCalldata, buildConfidentialTransferByHandleCalldata, @@ -9,6 +11,8 @@ import { decodeFlushERC7984ForwarderTokenCalldata, wrapInCallFromParent, approveMethodId, + wrapMethodId, + UINT64_MAX, delegateForUserDecryptionMethodId, aclMulticallMethodId, callFromParentMethodId, @@ -51,14 +55,19 @@ describe('Zama Utils', () => { approveMethodId.should.equal('0x095ea7b3'); }); + it('should have correct selector for wrap(address,uint256)', () => { + wrapMethodId.should.equal('0xbf376c7a'); + }); + it('method IDs should all be distinct', () => { const ids = new Set([ delegateForUserDecryptionMethodId, aclMulticallMethodId, callFromParentMethodId, approveMethodId, + wrapMethodId, ]); - ids.size.should.equal(4); + ids.size.should.equal(5); }); }); @@ -141,6 +150,91 @@ describe('Zama Utils', () => { }); }); + // ------------------------------------------------------------------------- + describe('buildWrapCalldata', () => { + const RECIPIENT = '0x1111111111111111111111111111111111111111'; + const EXACT_AMOUNT = '1000000'; + const RATE_ONE = '1'; + const RATE_1E12 = '1000000000000'; // hteth:ctest1 + + describe('output format', () => { + it('should produce a 0x-prefixed hex string', () => { + const calldata = buildWrapCalldata(RECIPIENT, EXACT_AMOUNT, RATE_ONE); + calldata.should.be.a.String(); + calldata.should.startWith('0x'); + }); + + it('should have exact length: 4-byte selector + 2 × 32-byte ABI words = 68 bytes (138 chars)', () => { + const calldata = buildWrapCalldata(RECIPIENT, EXACT_AMOUNT, RATE_ONE); + calldata.length.should.equal(138); + }); + + it('should start with wrap selector 0xbf376c7a', () => { + const calldata = buildWrapCalldata(RECIPIENT, EXACT_AMOUNT, RATE_ONE); + calldata.slice(0, 10).should.equal(wrapMethodId); + calldata.slice(0, 10).should.equal('0xbf376c7a'); + }); + }); + + describe('ABI parameter encoding', () => { + it('should encode recipient in the first ABI word', () => { + const calldata = buildWrapCalldata(RECIPIENT, EXACT_AMOUNT, RATE_ONE); + const word1 = calldata.slice(10, 74); + word1.should.equal(RECIPIENT.slice(2).toLowerCase().padStart(64, '0')); + }); + + it('should encode amount in the second ABI word', () => { + const calldata = buildWrapCalldata(RECIPIENT, EXACT_AMOUNT, RATE_ONE); + const word2 = calldata.slice(74, 138); + word2.should.equal(BigInt(EXACT_AMOUNT).toString(16).padStart(64, '0')); + }); + + it('should round-trip through decodeWrapCalldata', () => { + const calldata = buildWrapCalldata(RECIPIENT, EXACT_AMOUNT, RATE_ONE); + const decoded = decodeWrapCalldata(calldata); + decoded.to.toLowerCase().should.equal(RECIPIENT.toLowerCase()); + decoded.amount.should.equal(EXACT_AMOUNT); + }); + }); + + describe('validation', () => { + it('should reject an invalid to address', () => { + (() => buildWrapCalldata('not-an-address', EXACT_AMOUNT, RATE_ONE)).should.throw(/invalid to address/); + }); + + it('should reject a bad EIP-55 checksum', () => { + // Mixed case that does not match the correct checksum (same pattern as approve tests) + const badChecksum = '0x2Debbe0487ef921df4457f9e36ed05be2df1ac75'; + (() => buildWrapCalldata(badChecksum, EXACT_AMOUNT, RATE_ONE)).should.throw(/invalid to address/); + }); + + it('should reject amount 0', () => { + (() => buildWrapCalldata(RECIPIENT, 0, RATE_ONE)).should.throw(/amount must be > 0/); + }); + + it('should reject a negative amount', () => { + (() => buildWrapCalldata(RECIPIENT, -1, RATE_ONE)).should.throw(/amount must be > 0/); + }); + + it('should reject when amount × rate exceeds uint64', () => { + // UINT64_MAX / 1e12 + 1 overflows for ctest1-style rate + const tooLarge = (UINT64_MAX / BigInt(RATE_1E12) + 1n).toString(); + (() => buildWrapCalldata(RECIPIENT, tooLarge, RATE_1E12)).should.throw(/exceeds uint64/); + }); + + it('should accept amount × rate that fits uint64 for rate 1e12', () => { + const ok = (UINT64_MAX / BigInt(RATE_1E12)).toString(); + const calldata = buildWrapCalldata(RECIPIENT, ok, RATE_1E12); + calldata.slice(0, 10).should.equal(wrapMethodId); + }); + + it('should encode without rate when rate is omitted (deserialize path)', () => { + const calldata = buildWrapCalldata(RECIPIENT, EXACT_AMOUNT); + calldata.slice(0, 10).should.equal(wrapMethodId); + }); + }); + }); + // ------------------------------------------------------------------------- describe('buildDelegationCalldata', () => { describe('output format', () => { diff --git a/modules/sdk-coin-eth/src/erc7984Token.ts b/modules/sdk-coin-eth/src/erc7984Token.ts index a107c05af5..8ae117b0ba 100644 --- a/modules/sdk-coin-eth/src/erc7984Token.ts +++ b/modules/sdk-coin-eth/src/erc7984Token.ts @@ -30,6 +30,10 @@ import { VerifyEthTransactionOptions, aclMulticallMethodId, callFromParentMethodId, + decodeWrapCalldata, + wrapMethodId, + assertAmountTimesRateFitsUint64, + decodeTransferData, } from '@bitgo/abstract-eth'; import { bip32 } from '@bitgo/secp256k1'; import * as _ from 'lodash'; @@ -150,6 +154,9 @@ export class Erc7984Token extends Eth { if (params.txParams?.type === 'enabletoken') { return this.verifyEnableTokenTransaction(params); } + if (params.txParams?.type === 'wrap') { + return this.verifyWrapTransaction(params); + } if (this.isConsolidationTransaction(params)) { return this.verifyConfidentialConsolidation(params); } @@ -170,6 +177,105 @@ export class Erc7984Token extends Eth { ); } + /** + * Verifies WrapERC7984 (shield) transactions. + * + * TSS / direct shape: + * tx.to = wrapper contract + * tx.data = wrap(baseAddress, amount) + * + * Multisig shape: + * tx.to = wallet contract + * tx.data = sendMultiSig(wrapper, 0, wrap(baseAddress, amount), ...) + * + * Checks: wrapper address, self-directed `to` == base, amount > 0, amount matches + * recipients, and amount × rate fits uint64 when statics rate is present. + */ + private async verifyWrapTransaction(params: VerifyEthTransactionOptions): Promise { + const { txParams, txPrebuild, wallet } = params; + + if (!txPrebuild?.txHex) { + throw new Error('verifyWrapTransaction: missing txHex in txPrebuild'); + } + + const txBuilder = this.getTransactionBuilder(); + txBuilder.from(txPrebuild.txHex); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + let wrapperAddress: string; + let wrapCalldata: string; + + try { + if (txJson.data.toLowerCase().startsWith(sendMultisigMethodId.toLowerCase())) { + const decoded = decodeTransferData(txJson.data); + wrapperAddress = decoded.to; + wrapCalldata = decoded.data as string; + if (decoded.amount !== '0') { + throw new Error(`expected sendMultiSig value 0 but got ${decoded.amount}`); + } + } else if (txJson.data.toLowerCase().startsWith(wrapMethodId.toLowerCase())) { + wrapperAddress = txJson.to as string; + wrapCalldata = txJson.data; + } else { + throw new Error(`unexpected method ID ${txJson.data.slice(0, 10)}`); + } + } catch (e) { + throw new Error(`verifyWrapTransaction: failed to decode wrap calldata — ${(e as Error).message}`); + } + + if (wrapperAddress.toLowerCase() !== this.tokenContractAddress.toLowerCase()) { + throw new Error( + `verifyWrapTransaction: wrapper address mismatch — ` + + `expected ${this.tokenContractAddress}, got ${wrapperAddress}` + ); + } + + let to: string; + let amount: string; + try { + ({ to, amount } = decodeWrapCalldata(wrapCalldata)); + } catch (e) { + throw new Error(`verifyWrapTransaction: invalid wrap inner calldata — ${(e as Error).message}`); + } + + if (!Erc7984Token.isPositiveIntegerString(amount)) { + throw new Error(`verifyWrapTransaction: amount must be a positive integer string, got '${amount}'`); + } + + const baseAddress = this.getWalletBaseAddress(wallet); + if (!baseAddress) { + throw new Error('verifyWrapTransaction: unable to determine wallet base address'); + } + if (to.toLowerCase() !== baseAddress.toLowerCase()) { + throw new Error( + `verifyWrapTransaction: wrap recipient must equal wallet base address — expected ${baseAddress}, got ${to}` + ); + } + + const expectedAmount = txParams?.recipients?.[0]?.amount ?? txPrebuild.buildParams?.recipients?.[0]?.amount; + if (expectedAmount !== undefined && String(expectedAmount) !== amount) { + throw new Error( + `verifyWrapTransaction: amount mismatch — calldata has '${amount}' but params have '${expectedAmount}'` + ); + } + + const rate = this.tokenConfig.rate; + if (rate !== undefined) { + try { + assertAmountTimesRateFitsUint64(amount, rate); + } catch (e) { + throw new Error(`verifyWrapTransaction: ${(e as Error).message}`); + } + } + + if (txJson.value !== undefined && txJson.value !== '0' && txJson.value !== 0) { + throw new Error(`verifyWrapTransaction: expected transaction value 0 but got ${txJson.value}`); + } + + return true; + } + private getWalletBaseAddress(wallet: VerifyEthTransactionOptions['wallet']): string | undefined { if (!wallet) { return undefined; diff --git a/modules/sdk-coin-eth/test/unit/erc7984Token.ts b/modules/sdk-coin-eth/test/unit/erc7984Token.ts index 3753114e1f..3d35b3a0c1 100644 --- a/modules/sdk-coin-eth/test/unit/erc7984Token.ts +++ b/modules/sdk-coin-eth/test/unit/erc7984Token.ts @@ -17,6 +17,7 @@ import { TransactionType, Wallet } from '@bitgo/sdk-core'; import { buildMulticallDelegationCalldata, buildFlushERC7984ForwarderTokenCalldata, + buildWrapCalldata, sendMultiSigData, wrapInCallFromParent, decodeTokenAddressesFromDelegationCalldata, @@ -1455,6 +1456,124 @@ describe('Erc7984Token – queryConfidentialBalance()', function () { }); }); +// --------------------------------------------------------------------------- +// verifyTransaction – WrapERC7984 (shield) +// --------------------------------------------------------------------------- + +const WRAP_BASE_ADDRESS = '0x1111111111111111111111111111111111111111'; +const WRAP_AMOUNT = '1000000'; +const WRAP_RATE = '1'; + +async function buildDirectWrapTxHex(tokenAddress: string, recipient: string, amount: string): Promise { + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + txBuilder.type(TransactionType.WrapERC7984); + txBuilder.contract(tokenAddress); + txBuilder.wrapRecipient(recipient); + txBuilder.wrapAmount(amount); + txBuilder.wrapRate(WRAP_RATE); + const tx = await txBuilder.build(); + return tx.toBroadcastFormat(); +} + +async function buildMultisigWrapTxHex(tokenAddress: string, recipient: string, amount: string): Promise { + const wrapCalldata = buildWrapCalldata(recipient, amount, WRAP_RATE); + const sendData = sendMultiSigData( + tokenAddress, + '0', + wrapCalldata, + Math.floor(Date.now() / 1000) + 3600, + 14, + DUMMY_MULTISIG_SIGNATURE + ); + + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + txBuilder.type(TransactionType.ContractCall); + txBuilder.contract(MULTISIG_WALLET_CONTRACT); + txBuilder.data(sendData); + const tx = await txBuilder.build(); + return tx.toBroadcastFormat(); +} + +describe('verifyTransaction – WrapERC7984', 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 direct wrap tx (TSS shape)', async function () { + const txHex = await buildDirectWrapTxHex(CTEST1_TOKEN_ADDRESS, WRAP_BASE_ADDRESS, WRAP_AMOUNT); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: WRAP_BASE_ADDRESS }, + }); + + const result = await coin.verifyTransaction({ + txParams: { + type: 'wrap', + recipients: [{ address: WRAP_BASE_ADDRESS, amount: WRAP_AMOUNT }], + } as any, + txPrebuild: { txHex } as any, + wallet, + }); + result.should.equal(true); + }); + + it('should verify a valid multisig wrap tx (sendMultiSig → wrap)', async function () { + const txHex = await buildMultisigWrapTxHex(CTEST1_TOKEN_ADDRESS, WRAP_BASE_ADDRESS, WRAP_AMOUNT); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: WRAP_BASE_ADDRESS }, + }); + + const result = await coin.verifyTransaction({ + txParams: { + type: 'wrap', + recipients: [{ address: WRAP_BASE_ADDRESS, amount: WRAP_AMOUNT }], + } as any, + txPrebuild: { txHex } as any, + wallet, + }); + result.should.equal(true); + }); + + it('should reject wrap when recipient does not match wallet base address', async function () { + const txHex = await buildDirectWrapTxHex(CTEST1_TOKEN_ADDRESS, WRAP_BASE_ADDRESS, WRAP_AMOUNT); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + }); + + await coin + .verifyTransaction({ + txParams: { type: 'wrap', recipients: [{ address: WRAP_BASE_ADDRESS, amount: WRAP_AMOUNT }] } as any, + txPrebuild: { txHex } as any, + wallet, + }) + .should.be.rejectedWith(/wrap recipient must equal wallet base address/); + }); + + it('should reject wrap when amount mismatches recipients', async function () { + const txHex = await buildDirectWrapTxHex(CTEST1_TOKEN_ADDRESS, WRAP_BASE_ADDRESS, WRAP_AMOUNT); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: WRAP_BASE_ADDRESS }, + }); + + await coin + .verifyTransaction({ + txParams: { type: 'wrap', recipients: [{ address: WRAP_BASE_ADDRESS, amount: '999' }] } as any, + txPrebuild: { txHex } as any, + wallet, + }) + .should.be.rejectedWith(/amount mismatch/); + }); +}); + // --------------------------------------------------------------------------- // setGasLimit() override tests // --------------------------------------------------------------------------- diff --git a/modules/sdk-coin-eth/test/unit/transactionBuilder/flushTokens.ts b/modules/sdk-coin-eth/test/unit/transactionBuilder/flushTokens.ts index 097127f13f..9f058318b5 100644 --- a/modules/sdk-coin-eth/test/unit/transactionBuilder/flushTokens.ts +++ b/modules/sdk-coin-eth/test/unit/transactionBuilder/flushTokens.ts @@ -9,8 +9,13 @@ import { Transaction, } from '../../../src'; import { getBuilder } from '../getBuilder'; -// eslint-disable-next-line import/no-internal-modules -import { runFlushNftTests, runFlushERC7984Tests } from '@bitgo/abstract-eth/test/unit/transactionBuilder'; +/* eslint-disable import/no-internal-modules */ +import { + runFlushNftTests, + runFlushERC7984Tests, + runWrapERC7984Tests, +} from '@bitgo/abstract-eth/test/unit/transactionBuilder'; +/* eslint-enable import/no-internal-modules */ // Run the shared flush NFT tests from abstract-eth describe('ETH Flush NFT Tests (from abstract-eth)', () => { @@ -22,6 +27,11 @@ describe('ETH FlushERC7984ForwarderToken Tests (from abstract-eth)', () => { runFlushERC7984Tests('eth', getBuilder); }); +// Run the shared WrapERC7984 tests from abstract-eth +describe('ETH WrapERC7984 Tests (from abstract-eth)', () => { + runWrapERC7984Tests('eth', getBuilder); +}); + describe('Eth Transaction builder flush tokens (ETH-specific)', function () { const defaultKeyPair = new KeyPair({ prv: 'FAC4D04AA0025ECF200D74BC9B5E4616E4B8338B69B61362AAAD49F76E68EF28', diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index 02777ac187..668e8a8820 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -161,6 +161,8 @@ export enum TransactionType { // Flush ERC-7984 confidential tokens from a forwarder address to the parent wallet // via forwarder.callFromParent(tokenAddr, 0, confidentialTransfer(parentAddr, handle)) FlushERC7984ForwarderToken, + // Wrap (shield) an underlying ERC-20 into an ERC-7984 confidential token via wrap(to, amount) + WrapERC7984, } /**