diff --git a/modules/abstract-eth/src/lib/iface.ts b/modules/abstract-eth/src/lib/iface.ts index e54097767c..e14232c6b3 100644 --- a/modules/abstract-eth/src/lib/iface.ts +++ b/modules/abstract-eth/src/lib/iface.ts @@ -175,3 +175,16 @@ export interface WrapERC7984Data { /** Underlying amount wrapped (base units, decimal string) */ amount: string; } + +export interface UnwrapERC7984Data { + /** Confidential wrapper contract (tx.to) */ + wrapperAddress: string; + /** Source of confidential balance */ + from: string; + /** Recipient of released underlying ERC-20 */ + to: string; + /** bytes32 encrypted amount handle */ + encryptedAmount: string; + /** Encryption input proof */ + inputProof: string; +} diff --git a/modules/abstract-eth/src/lib/transactionBuilder.ts b/modules/abstract-eth/src/lib/transactionBuilder.ts index 1fae998123..88474b8fa9 100644 --- a/modules/abstract-eth/src/lib/transactionBuilder.ts +++ b/modules/abstract-eth/src/lib/transactionBuilder.ts @@ -27,6 +27,7 @@ import { SignatureParts, TxData, WrapERC7984Data, + UnwrapERC7984Data, } from './iface'; import { calculateForwarderAddress, @@ -38,6 +39,7 @@ import { decodeFlushERC1155TokensData, decodeFlushERC7984ForwarderTokenData, decodeWrapERC7984Data, + decodeUnwrapERC7984Data, decodeWalletCreationData, flushCoinsData, flushTokensData, @@ -51,7 +53,7 @@ import { getV1WalletInitializationData, getCreateForwarderParamsAndTypes, } from './utils'; -import { buildFlushERC7984ForwarderTokenCalldata, buildWrapCalldata } from './zamaUtils'; +import { buildFlushERC7984ForwarderTokenCalldata, buildWrapCalldata, buildUnwrapCalldata } from './zamaUtils'; import { defaultWalletVersion, walletSimpleConstructor } from './walletUtil'; import { ERC1155TransferBuilder } from './transferBuilders/transferBuilderERC1155'; import { ERC721TransferBuilder } from './transferBuilders/transferBuilderERC721'; @@ -97,6 +99,12 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { private _wrapAmount: string; // underlying amount to wrap (base units) private _wrapRate: string; // on-chain rate() for uint64 validation + // UnwrapERC7984 parameters + private _unwrapFrom: string; + private _unwrapTo: string; + private _unwrapEncryptedAmount: string; + private _unwrapInputProof: string; + // Send and AddressInitialization transaction specific parameters protected _transfer: TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984; private _contractAddress: string; @@ -185,6 +193,8 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { return this.buildFlushERC7984ForwarderTokenTransaction(); case TransactionType.WrapERC7984: return this.buildWrapERC7984Transaction(); + case TransactionType.UnwrapERC7984: + return this.buildUnwrapERC7984Transaction(); default: throw new BuildTransactionError('Unsupported transaction type'); } @@ -347,6 +357,15 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { // rate is not encoded in calldata; callers that rebuild must set wrapRate() again break; } + case TransactionType.UnwrapERC7984: { + this.setContract(transactionJson.to); + const unwrapData: UnwrapERC7984Data = decodeUnwrapERC7984Data(transactionJson.data, transactionJson.to!); + this.unwrapFrom(unwrapData.from); + this.unwrapTo(unwrapData.to); + this.unwrapEncryptedAmount(unwrapData.encryptedAmount); + this.unwrapInputProof(unwrapData.inputProof); + break; + } default: throw new BuildTransactionError('Unsupported transaction type'); // TODO: Add other cases of deserialization @@ -510,6 +529,13 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this.validateWrapRecipient(); this.validateWrapAmount(); break; + case TransactionType.UnwrapERC7984: + this.validateContractAddress(); + this.validateUnwrapFrom(); + this.validateUnwrapTo(); + this.validateUnwrapEncryptedAmount(); + this.validateUnwrapInputProof(); + break; default: throw new BuildTransactionError('Unsupported transaction type'); } @@ -605,6 +631,30 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } } + private validateUnwrapFrom(): void { + if (!this._unwrapFrom) { + throw new BuildTransactionError('Invalid transaction: missing unwrapFrom'); + } + } + + private validateUnwrapTo(): void { + if (!this._unwrapTo) { + throw new BuildTransactionError('Invalid transaction: missing unwrapTo'); + } + } + + private validateUnwrapEncryptedAmount(): void { + if (!this._unwrapEncryptedAmount) { + throw new BuildTransactionError('Invalid transaction: missing unwrapEncryptedAmount'); + } + } + + private validateUnwrapInputProof(): void { + if (!this._unwrapInputProof) { + throw new BuildTransactionError('Invalid transaction: missing unwrapInputProof'); + } + } + private setContract(address: string | undefined): void { if (address === undefined) { throw new BuildTransactionError('Undefined recipient address'); @@ -1172,4 +1222,56 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } // endregion + + // region UnwrapERC7984 builder methods + + /** + * Set the unwrap `from` address (source of confidential balance). + */ + unwrapFrom(address: string): void { + if (!isValidEthAddress(address)) { + throw new BuildTransactionError('Invalid address: ' + address); + } + this._unwrapFrom = address; + } + + /** + * Set the unwrap `to` address (recipient of released ERC-20). + */ + unwrapTo(address: string): void { + if (!isValidEthAddress(address)) { + throw new BuildTransactionError('Invalid address: ' + address); + } + this._unwrapTo = address; + } + + /** + * Set the bytes32 encrypted amount handle for unwrap. + */ + unwrapEncryptedAmount(encryptedAmount: string): void { + this._unwrapEncryptedAmount = encryptedAmount; + } + + /** + * Set the encryption input proof for unwrap. + */ + unwrapInputProof(inputProof: string): void { + this._unwrapInputProof = inputProof; + } + + /** + * Build an UnwrapERC7984 (unshield phase-1) transaction. + * Does not set gasLimit — WP owns gas. + */ + private buildUnwrapERC7984Transaction(): TxData { + const data = buildUnwrapCalldata( + this._unwrapFrom, + this._unwrapTo, + this._unwrapEncryptedAmount, + this._unwrapInputProof + ); + return this.buildBase(data); + } + + // endregion } diff --git a/modules/abstract-eth/src/lib/utils.ts b/modules/abstract-eth/src/lib/utils.ts index 73b888c230..970141de00 100644 --- a/modules/abstract-eth/src/lib/utils.ts +++ b/modules/abstract-eth/src/lib/utils.ts @@ -43,6 +43,7 @@ import { WalletInitializationData, ForwarderInitializationData, WrapERC7984Data, + UnwrapERC7984Data, } from './iface'; import { KeyPair } from './keyPair'; import { @@ -95,8 +96,10 @@ import { callFromParentTypes, decodeFlushERC7984ForwarderTokenCalldata, decodeWrapCalldata, + decodeUnwrapCalldata, delegateForUserDecryptionMethodId, wrapMethodId, + unwrapMethodId, } from './zamaUtils'; /** @@ -848,6 +851,23 @@ export function decodeWrapERC7984Data(data: string, to: string): WrapERC7984Data }; } +/** + * Decode an UnwrapERC7984 transaction's calldata into its component parts. + * + * @param data The unwrap(address,address,bytes32,bytes) calldata hex + * @param to The transaction `to` field (wrapper contract address) + */ +export function decodeUnwrapERC7984Data(data: string, to: string): UnwrapERC7984Data { + const decoded = decodeUnwrapCalldata(data); + return { + wrapperAddress: to, + from: decoded.from, + to: decoded.to, + encryptedAmount: decoded.encryptedAmount, + inputProof: decoded.inputProof, + }; +} + /** * 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 @@ -934,6 +954,7 @@ const transactionTypesMap = { // explicitly when building from a known delegation template. [delegateForUserDecryptionMethodId]: TransactionType.DecryptionDelegation, [wrapMethodId]: TransactionType.WrapERC7984, + [unwrapMethodId]: TransactionType.UnwrapERC7984, }; /** diff --git a/modules/abstract-eth/src/lib/zamaUtils.ts b/modules/abstract-eth/src/lib/zamaUtils.ts index 31a23bb2e3..3daa8b6e48 100644 --- a/modules/abstract-eth/src/lib/zamaUtils.ts +++ b/modules/abstract-eth/src/lib/zamaUtils.ts @@ -13,10 +13,10 @@ 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; +export const unwrapTypes = ['address', 'address', 'bytes32', 'bytes'] 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) * = keccak256('delegateForUserDecryption(address,address,uint64)')[0:4] @@ -56,6 +56,13 @@ export const approveMethodId = addHexPrefix(EthereumAbi.methodID('approve', [... */ export const wrapMethodId = addHexPrefix(EthereumAbi.methodID('wrap', [...wrapTypes]).toString('hex')); +/** + * Function selector for ERC-7984 unwrap(address,address,bytes32,bytes) + * = keccak256('unwrap(address,address,bytes32,bytes)')[0:4] + * Burns confidential balance and requests release of underlying ERC-20 escrow. + */ +export const unwrapMethodId = addHexPrefix(EthereumAbi.methodID('unwrap', [...unwrapTypes]).toString('hex')); + // --------------------------------------------------------------------------- // Encoding functions // --------------------------------------------------------------------------- @@ -195,6 +202,76 @@ export function decodeWrapCalldata(data: string): { to: string; amount: string } }; } +/** + * Encodes ERC-7984 `unwrap(from, to, encryptedAmount, inputProof)` calldata for unshield. + * + * Calldata is sent to the confidential wrapper. Phase-1 unshield is self-directed: + * `from` and `to` are both the wallet base address. `encryptedAmount` is the + * FHE-encrypted burn amount (bytes32 handle) and `inputProof` is the Zama + * encryption proof — both produced by WP `ZamaRelayerService.encryptAmount`. + * + * Does not set gasLimit — WP owns gas. + * + * @param from Source of confidential balance (base address) + * @param to Recipient of released ERC-20 (base address in v1) + * @param encryptedAmount bytes32 encrypted amount handle (0x-prefixed) + * @param inputProof Encryption input proof bytes (0x-prefixed) + * @returns ABI-encoded calldata hex string (0x-prefixed) + * @throws {Error} if addresses or ciphertext fields are invalid + */ +export function buildUnwrapCalldata(from: string, to: string, encryptedAmount: string, inputProof: string): string { + let checksummedFrom: string; + let checksummedTo: string; + try { + checksummedFrom = ethers.utils.getAddress(from); + } catch { + throw new Error(`buildUnwrapCalldata: invalid from address '${from}'`); + } + try { + checksummedTo = ethers.utils.getAddress(to); + } catch { + throw new Error(`buildUnwrapCalldata: invalid to address '${to}'`); + } + + if (!ethers.utils.isHexString(encryptedAmount) || ethers.utils.hexDataLength(encryptedAmount) !== 32) { + throw new Error(`buildUnwrapCalldata: encryptedAmount must be a 32-byte hex string, got '${encryptedAmount}'`); + } + if (!ethers.utils.isHexString(inputProof) || ethers.utils.hexDataLength(inputProof) === 0) { + throw new Error(`buildUnwrapCalldata: inputProof must be a non-empty hex string, got '${inputProof}'`); + } + + const method = EthereumAbi.methodID('unwrap', [...unwrapTypes]); + const args = EthereumAbi.rawEncode( + [...unwrapTypes], + [checksummedFrom, checksummedTo, toBuffer(encryptedAmount), toBuffer(inputProof)] + ); + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); +} + +/** + * Decodes ERC-7984 `unwrap(from, to, encryptedAmount, inputProof)` calldata. + * + * @param data ABI-encoded unwrap calldata (0x-prefixed) + */ +export function decodeUnwrapCalldata(data: string): { + from: string; + to: string; + encryptedAmount: string; + inputProof: string; +} { + if (!data.toLowerCase().startsWith(unwrapMethodId.toLowerCase())) { + throw new Error(`decodeUnwrapCalldata: expected unwrap selector ${unwrapMethodId}, got ${data.slice(0, 10)}`); + } + const abiCoder = new ethers.utils.AbiCoder(); + const decoded = abiCoder.decode([...unwrapTypes], '0x' + data.slice(10)); + return { + from: ethers.utils.getAddress(decoded[0]), + to: ethers.utils.getAddress(decoded[1]), + encryptedAmount: ethers.utils.hexlify(decoded[2]), + inputProof: ethers.utils.hexlify(decoded[3]), + }; +} + /** * 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 74ec0719d3..b5051ea29f 100644 --- a/modules/abstract-eth/test/unit/transactionBuilder/index.ts +++ b/modules/abstract-eth/test/unit/transactionBuilder/index.ts @@ -5,3 +5,4 @@ export * from './flushNft'; export * from './decryptionDelegation'; export * from './flushERC7984'; export * from './wrapERC7984'; +export * from './unwrapERC7984'; diff --git a/modules/abstract-eth/test/unit/transactionBuilder/unwrapERC7984.ts b/modules/abstract-eth/test/unit/transactionBuilder/unwrapERC7984.ts new file mode 100644 index 0000000000..8ae2ec8315 --- /dev/null +++ b/modules/abstract-eth/test/unit/transactionBuilder/unwrapERC7984.ts @@ -0,0 +1,148 @@ +/** + * TransactionBuilder tests for UnwrapERC7984 transaction type. + */ +import { TransactionType } from '@bitgo/sdk-core'; +import should from 'should'; +import { ETHTransactionType, TransactionBuilder } from '../../../src'; +import { buildUnwrapCalldata, decodeUnwrapCalldata, unwrapMethodId } from '../../../src/lib/zamaUtils'; +import { classifyTransaction } from '../../../src/lib/utils'; + +const WRAPPER_ADDRESS = '0x2debbe0487ef921df4457f9e36ed05be2df1ac75'; // hteth:cusdt +const BASE_ADDRESS = '0x1111111111111111111111111111111111111111'; +const ENCRYPTED_AMOUNT = '0x' + 'ab'.repeat(32); +const INPUT_PROOF = '0x' + 'cd'.repeat(64); +const TEST_PRV_KEY = 'FAC4D04AA0025ECF200D74BC9B5E4616E4B8338B69B61362AAAD49F76E68EF28'; + +export function runUnwrapERC7984Tests(coinName: string, getBuilder: (coin: string) => TransactionBuilder): void { + describe(`${coinName} transaction builder — UnwrapERC7984`, () => { + let txBuilder: TransactionBuilder; + + beforeEach(() => { + txBuilder = getBuilder(coinName); + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + }); + + describe('classifyTransaction', () => { + it('should classify unwrap(...) as UnwrapERC7984', () => { + const calldata = buildUnwrapCalldata(BASE_ADDRESS, BASE_ADDRESS, ENCRYPTED_AMOUNT, INPUT_PROOF); + should.equal(classifyTransaction(calldata), TransactionType.UnwrapERC7984); + }); + + it('should NOT classify approve as UnwrapERC7984', () => { + should.equal(classifyTransaction('0x095ea7b3' + '00'.repeat(64)), TransactionType.ContractCall); + }); + }); + + describe('build from scratch', () => { + it('should build an UnwrapERC7984 transaction', async () => { + txBuilder.type(TransactionType.UnwrapERC7984); + txBuilder.contract(WRAPPER_ADDRESS); + txBuilder.unwrapFrom(BASE_ADDRESS); + txBuilder.unwrapTo(BASE_ADDRESS); + txBuilder.unwrapEncryptedAmount(ENCRYPTED_AMOUNT); + txBuilder.unwrapInputProof(INPUT_PROOF); + + const tx = await txBuilder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.UnwrapERC7984); + json.to.toLowerCase().should.equal(WRAPPER_ADDRESS.toLowerCase()); + json.data.should.startWith(unwrapMethodId); + json.value.should.equal('0'); + + const decoded = decodeUnwrapCalldata(json.data); + decoded.from.toLowerCase().should.equal(BASE_ADDRESS.toLowerCase()); + decoded.to.toLowerCase().should.equal(BASE_ADDRESS.toLowerCase()); + decoded.encryptedAmount.should.equal(ENCRYPTED_AMOUNT); + decoded.inputProof.should.equal(INPUT_PROOF); + }); + + 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.UnwrapERC7984); + builder.contract(WRAPPER_ADDRESS); + builder.unwrapFrom(BASE_ADDRESS); + builder.unwrapTo(BASE_ADDRESS); + builder.unwrapEncryptedAmount(ENCRYPTED_AMOUNT); + builder.unwrapInputProof(INPUT_PROOF); + + const tx = await builder.build(); + const json = tx.toJson(); + + should.equal(tx.type, TransactionType.UnwrapERC7984); + json._type.should.equal(ETHTransactionType.EIP1559); + json.data.should.startWith(unwrapMethodId); + }); + }); + + describe('signing and round-trip', () => { + it('should produce a signed transaction with v, r, s and from fields', async () => { + txBuilder.type(TransactionType.UnwrapERC7984); + txBuilder.contract(WRAPPER_ADDRESS); + txBuilder.unwrapFrom(BASE_ADDRESS); + txBuilder.unwrapTo(BASE_ADDRESS); + txBuilder.unwrapEncryptedAmount(ENCRYPTED_AMOUNT); + txBuilder.unwrapInputProof(INPUT_PROOF); + 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.UnwrapERC7984); + }); + + it('should serialize and deserialize to the same transaction', async () => { + txBuilder.type(TransactionType.UnwrapERC7984); + txBuilder.contract(WRAPPER_ADDRESS); + txBuilder.unwrapFrom(BASE_ADDRESS); + txBuilder.unwrapTo(BASE_ADDRESS); + txBuilder.unwrapEncryptedAmount(ENCRYPTED_AMOUNT); + txBuilder.unwrapInputProof(INPUT_PROOF); + + 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.UnwrapERC7984); + }); + }); + + describe('validation', () => { + it('should reject missing unwrapFrom', async () => { + txBuilder.type(TransactionType.UnwrapERC7984); + txBuilder.contract(WRAPPER_ADDRESS); + txBuilder.unwrapTo(BASE_ADDRESS); + txBuilder.unwrapEncryptedAmount(ENCRYPTED_AMOUNT); + txBuilder.unwrapInputProof(INPUT_PROOF); + await txBuilder.build().should.be.rejectedWith(/missing unwrapFrom/); + }); + + it('should reject missing unwrapEncryptedAmount', async () => { + txBuilder.type(TransactionType.UnwrapERC7984); + txBuilder.contract(WRAPPER_ADDRESS); + txBuilder.unwrapFrom(BASE_ADDRESS); + txBuilder.unwrapTo(BASE_ADDRESS); + txBuilder.unwrapInputProof(INPUT_PROOF); + await txBuilder.build().should.be.rejectedWith(/missing unwrapEncryptedAmount/); + }); + }); + }); +} diff --git a/modules/abstract-eth/test/unit/zamaUtils.ts b/modules/abstract-eth/test/unit/zamaUtils.ts index 5423c88b8a..57478121a3 100644 --- a/modules/abstract-eth/test/unit/zamaUtils.ts +++ b/modules/abstract-eth/test/unit/zamaUtils.ts @@ -4,6 +4,8 @@ import { buildApproveCalldata, buildWrapCalldata, decodeWrapCalldata, + buildUnwrapCalldata, + decodeUnwrapCalldata, buildDelegationCalldata, buildMulticallDelegationCalldata, buildConfidentialTransferByHandleCalldata, @@ -13,6 +15,7 @@ import { approveMethodId, wrapMethodId, UINT64_MAX, + unwrapMethodId, delegateForUserDecryptionMethodId, aclMulticallMethodId, callFromParentMethodId, @@ -59,6 +62,10 @@ describe('Zama Utils', () => { wrapMethodId.should.equal('0xbf376c7a'); }); + it('should have correct selector for unwrap(address,address,bytes32,bytes)', () => { + unwrapMethodId.should.equal('0x5bf4ef06'); + }); + it('method IDs should all be distinct', () => { const ids = new Set([ delegateForUserDecryptionMethodId, @@ -66,8 +73,9 @@ describe('Zama Utils', () => { callFromParentMethodId, approveMethodId, wrapMethodId, + unwrapMethodId, ]); - ids.size.should.equal(5); + ids.size.should.equal(6); }); }); @@ -235,6 +243,60 @@ describe('Zama Utils', () => { }); }); + describe('buildUnwrapCalldata', () => { + const FROM = '0x1111111111111111111111111111111111111111'; + const TO = '0x1111111111111111111111111111111111111111'; + const ENCRYPTED_AMOUNT = '0x' + 'ab'.repeat(32); + const INPUT_PROOF = '0x' + 'cd'.repeat(64); + + describe('output format', () => { + it('should produce a 0x-prefixed hex string starting with unwrap selector', () => { + const calldata = buildUnwrapCalldata(FROM, TO, ENCRYPTED_AMOUNT, INPUT_PROOF); + calldata.should.be.a.String(); + calldata.should.startWith(unwrapMethodId); + calldata.slice(0, 10).should.equal('0x5bf4ef06'); + }); + + it('should round-trip through decodeUnwrapCalldata', () => { + const calldata = buildUnwrapCalldata(FROM, TO, ENCRYPTED_AMOUNT, INPUT_PROOF); + const decoded = decodeUnwrapCalldata(calldata); + decoded.from.toLowerCase().should.equal(FROM.toLowerCase()); + decoded.to.toLowerCase().should.equal(TO.toLowerCase()); + decoded.encryptedAmount.should.equal(ENCRYPTED_AMOUNT); + decoded.inputProof.should.equal(INPUT_PROOF); + }); + }); + + describe('validation', () => { + it('should reject an invalid from address', () => { + (() => buildUnwrapCalldata('not-an-address', TO, ENCRYPTED_AMOUNT, INPUT_PROOF)).should.throw( + /invalid from address/ + ); + }); + + it('should reject an invalid to address', () => { + (() => buildUnwrapCalldata(FROM, 'not-an-address', ENCRYPTED_AMOUNT, INPUT_PROOF)).should.throw( + /invalid to address/ + ); + }); + + it('should reject a bad EIP-55 checksum', () => { + const badChecksum = '0x2Debbe0487ef921df4457f9e36ed05be2df1ac75'; + (() => buildUnwrapCalldata(badChecksum, TO, ENCRYPTED_AMOUNT, INPUT_PROOF)).should.throw( + /invalid from address/ + ); + }); + + it('should reject a non-32-byte encryptedAmount', () => { + (() => buildUnwrapCalldata(FROM, TO, '0xabcd', INPUT_PROOF)).should.throw(/encryptedAmount must be a 32-byte/); + }); + + it('should reject an empty inputProof', () => { + (() => buildUnwrapCalldata(FROM, TO, ENCRYPTED_AMOUNT, '0x')).should.throw(/inputProof must be a non-empty/); + }); + }); + }); + // ------------------------------------------------------------------------- describe('buildDelegationCalldata', () => { describe('output format', () => { diff --git a/modules/sdk-coin-eth/src/erc7984Token.ts b/modules/sdk-coin-eth/src/erc7984Token.ts index 8ae117b0ba..fccd2000a6 100644 --- a/modules/sdk-coin-eth/src/erc7984Token.ts +++ b/modules/sdk-coin-eth/src/erc7984Token.ts @@ -33,6 +33,8 @@ import { decodeWrapCalldata, wrapMethodId, assertAmountTimesRateFitsUint64, + decodeUnwrapCalldata, + unwrapMethodId, decodeTransferData, } from '@bitgo/abstract-eth'; import { bip32 } from '@bitgo/secp256k1'; @@ -157,6 +159,9 @@ export class Erc7984Token extends Eth { if (params.txParams?.type === 'wrap') { return this.verifyWrapTransaction(params); } + if (params.txParams?.type === 'unwrap') { + return this.verifyUnwrapTransaction(params); + } if (this.isConsolidationTransaction(params)) { return this.verifyConfidentialConsolidation(params); } @@ -276,6 +281,97 @@ export class Erc7984Token extends Eth { return true; } + /** + * Verifies UnwrapERC7984 (unshield phase-1) transactions. + * + * TSS / direct shape: + * tx.to = wrapper contract + * tx.data = unwrap(base, base, encryptedAmount, inputProof) + * + * Multisig shape: + * tx.to = wallet contract + * tx.data = sendMultiSig(wrapper, 0, unwrap(base, base, ...), ...) + * + * v1 is self-directed only: from == to == wallet base address. + */ + private async verifyUnwrapTransaction(params: VerifyEthTransactionOptions): Promise { + const { txPrebuild, wallet } = params; + + if (!txPrebuild?.txHex) { + throw new Error('verifyUnwrapTransaction: 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 unwrapCalldata: string; + + try { + if (txJson.data.toLowerCase().startsWith(sendMultisigMethodId.toLowerCase())) { + const decoded = decodeTransferData(txJson.data); + wrapperAddress = decoded.to; + unwrapCalldata = 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(unwrapMethodId.toLowerCase())) { + wrapperAddress = txJson.to as string; + unwrapCalldata = txJson.data; + } else { + throw new Error(`unexpected method ID ${txJson.data.slice(0, 10)}`); + } + } catch (e) { + throw new Error(`verifyUnwrapTransaction: failed to decode unwrap calldata — ${(e as Error).message}`); + } + + if (wrapperAddress.toLowerCase() !== this.tokenContractAddress.toLowerCase()) { + throw new Error( + `verifyUnwrapTransaction: wrapper address mismatch — expected ${this.tokenContractAddress}, got ${wrapperAddress}` + ); + } + + let from: string; + let to: string; + let encryptedAmount: string; + let inputProof: string; + try { + ({ from, to, encryptedAmount, inputProof } = decodeUnwrapCalldata(unwrapCalldata)); + } catch (e) { + throw new Error(`verifyUnwrapTransaction: invalid unwrap inner calldata — ${(e as Error).message}`); + } + + const baseAddress = this.getWalletBaseAddress(wallet); + if (!baseAddress) { + throw new Error('verifyUnwrapTransaction: unable to determine wallet base address'); + } + if (from.toLowerCase() !== baseAddress.toLowerCase()) { + throw new Error( + `verifyUnwrapTransaction: unwrap from must equal wallet base address — expected ${baseAddress}, got ${from}` + ); + } + if (to.toLowerCase() !== baseAddress.toLowerCase()) { + throw new Error( + `verifyUnwrapTransaction: unwrap to must equal wallet base address — expected ${baseAddress}, got ${to}` + ); + } + + if (!encryptedAmount || encryptedAmount === '0x' || /^0x0+$/.test(encryptedAmount)) { + throw new Error('verifyUnwrapTransaction: encryptedAmount is missing or empty'); + } + if (!inputProof || inputProof === '0x') { + throw new Error('verifyUnwrapTransaction: inputProof is missing or empty'); + } + + if (txJson.value !== undefined && txJson.value !== '0' && txJson.value !== 0) { + throw new Error(`verifyUnwrapTransaction: 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 3d35b3a0c1..5fd79c43c6 100644 --- a/modules/sdk-coin-eth/test/unit/erc7984Token.ts +++ b/modules/sdk-coin-eth/test/unit/erc7984Token.ts @@ -18,6 +18,7 @@ import { buildMulticallDelegationCalldata, buildFlushERC7984ForwarderTokenCalldata, buildWrapCalldata, + buildUnwrapCalldata, sendMultiSigData, wrapInCallFromParent, decodeTokenAddressesFromDelegationCalldata, @@ -1574,6 +1575,156 @@ describe('verifyTransaction – WrapERC7984', function () { }); }); +// --------------------------------------------------------------------------- +// verifyTransaction – UnwrapERC7984 (unshield phase-1) +// --------------------------------------------------------------------------- + +const UNWRAP_BASE_ADDRESS = '0x1111111111111111111111111111111111111111'; +const UNWRAP_ENCRYPTED_AMOUNT = '0x' + 'ab'.repeat(32); +const UNWRAP_INPUT_PROOF = '0x' + 'cd'.repeat(64); + +async function buildDirectUnwrapTxHex( + tokenAddress: string, + from: string, + to: string, + encryptedAmount: string, + inputProof: string +): Promise { + const txBuilder = getBuilder('hteth') as TransactionBuilder; + txBuilder.fee({ fee: '1000000000', gasLimit: '200000' }); + txBuilder.counter(1); + txBuilder.type(TransactionType.UnwrapERC7984); + txBuilder.contract(tokenAddress); + txBuilder.unwrapFrom(from); + txBuilder.unwrapTo(to); + txBuilder.unwrapEncryptedAmount(encryptedAmount); + txBuilder.unwrapInputProof(inputProof); + const tx = await txBuilder.build(); + return tx.toBroadcastFormat(); +} + +async function buildMultisigUnwrapTxHex( + tokenAddress: string, + from: string, + to: string, + encryptedAmount: string, + inputProof: string +): Promise { + const unwrapCalldata = buildUnwrapCalldata(from, to, encryptedAmount, inputProof); + const sendData = sendMultiSigData( + tokenAddress, + '0', + unwrapCalldata, + 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 – UnwrapERC7984', 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 unwrap tx (TSS shape)', async function () { + const txHex = await buildDirectUnwrapTxHex( + CTEST1_TOKEN_ADDRESS, + UNWRAP_BASE_ADDRESS, + UNWRAP_BASE_ADDRESS, + UNWRAP_ENCRYPTED_AMOUNT, + UNWRAP_INPUT_PROOF + ); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: UNWRAP_BASE_ADDRESS }, + }); + + const result = await coin.verifyTransaction({ + txParams: { type: 'unwrap' } as any, + txPrebuild: { txHex } as any, + wallet, + }); + result.should.equal(true); + }); + + it('should verify a valid multisig unwrap tx (sendMultiSig → unwrap)', async function () { + const txHex = await buildMultisigUnwrapTxHex( + CTEST1_TOKEN_ADDRESS, + UNWRAP_BASE_ADDRESS, + UNWRAP_BASE_ADDRESS, + UNWRAP_ENCRYPTED_AMOUNT, + UNWRAP_INPUT_PROOF + ); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: UNWRAP_BASE_ADDRESS }, + }); + + const result = await coin.verifyTransaction({ + txParams: { type: 'unwrap' } as any, + txPrebuild: { txHex } as any, + wallet, + }); + result.should.equal(true); + }); + + it('should reject unwrap when from does not match wallet base address', async function () { + const txHex = await buildDirectUnwrapTxHex( + CTEST1_TOKEN_ADDRESS, + UNWRAP_BASE_ADDRESS, + UNWRAP_BASE_ADDRESS, + UNWRAP_ENCRYPTED_AMOUNT, + UNWRAP_INPUT_PROOF + ); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + }); + + await coin + .verifyTransaction({ + txParams: { type: 'unwrap' } as any, + txPrebuild: { txHex } as any, + wallet, + }) + .should.be.rejectedWith(/unwrap from must equal wallet base address/); + }); + + it('should reject unwrap when to differs from from (non-self-directed)', async function () { + const other = '0x2222222222222222222222222222222222222222'; + const txHex = await buildDirectUnwrapTxHex( + CTEST1_TOKEN_ADDRESS, + UNWRAP_BASE_ADDRESS, + other, + UNWRAP_ENCRYPTED_AMOUNT, + UNWRAP_INPUT_PROOF + ); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: UNWRAP_BASE_ADDRESS }, + }); + + await coin + .verifyTransaction({ + txParams: { type: 'unwrap' } as any, + txPrebuild: { txHex } as any, + wallet, + }) + .should.be.rejectedWith(/unwrap to must equal wallet base address/); + }); +}); + // --------------------------------------------------------------------------- // 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 9f058318b5..1213592c3a 100644 --- a/modules/sdk-coin-eth/test/unit/transactionBuilder/flushTokens.ts +++ b/modules/sdk-coin-eth/test/unit/transactionBuilder/flushTokens.ts @@ -14,6 +14,7 @@ import { runFlushNftTests, runFlushERC7984Tests, runWrapERC7984Tests, + runUnwrapERC7984Tests, } from '@bitgo/abstract-eth/test/unit/transactionBuilder'; /* eslint-enable import/no-internal-modules */ @@ -32,6 +33,11 @@ describe('ETH WrapERC7984 Tests (from abstract-eth)', () => { runWrapERC7984Tests('eth', getBuilder); }); +// Run the shared UnwrapERC7984 tests from abstract-eth +describe('ETH UnwrapERC7984 Tests (from abstract-eth)', () => { + runUnwrapERC7984Tests('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 668e8a8820..5def193824 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -163,6 +163,8 @@ export enum TransactionType { FlushERC7984ForwarderToken, // Wrap (shield) an underlying ERC-20 into an ERC-7984 confidential token via wrap(to, amount) WrapERC7984, + // Unwrap (unshield) an ERC-7984 confidential token via unwrap(from, to, encryptedAmount, inputProof) + UnwrapERC7984, } /**