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
9 changes: 9 additions & 0 deletions modules/abstract-eth/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
82 changes: 80 additions & 2 deletions modules/abstract-eth/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,6 +37,7 @@ import {
decodeFlushERC721TokensData,
decodeFlushERC1155TokensData,
decodeFlushERC7984ForwarderTokenData,
decodeWrapERC7984Data,
decodeWalletCreationData,
flushCoinsData,
flushTokensData,
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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
}
19 changes: 19 additions & 0 deletions modules/abstract-eth/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
TxData,
WalletInitializationData,
ForwarderInitializationData,
WrapERC7984Data,
} from './iface';
import { KeyPair } from './keyPair';
import {
Expand Down Expand Up @@ -93,7 +94,9 @@ import {
callFromParentMethodId,
callFromParentTypes,
decodeFlushERC7984ForwarderTokenCalldata,
decodeWrapCalldata,
delegateForUserDecryptionMethodId,
wrapMethodId,
} from './zamaUtils';

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
};

/**
Expand Down
108 changes: 108 additions & 0 deletions modules/abstract-eth/src/lib/zamaUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './walletInitialization';
export * from './flushNft';
export * from './decryptionDelegation';
export * from './flushERC7984';
export * from './wrapERC7984';
Loading
Loading