Skip to content
Merged
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
124 changes: 124 additions & 0 deletions modules/abstract-eth/src/lib/decryptionDelegationBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { buildMulticallDelegationCalldata, wrapInCallFromParent } from './zamaUtils';

/**
* Parameters for building a Zama ERC-7984 decryption delegation transaction.
*/
export interface DecryptionDelegationBuilderParams {
/** Address of the Zama ACL contract on the target network. */
aclContractAddress: string;

/**
* BitGo enterprise viewing key address that receives decryption rights.
*/
delegateAddress: string;

/**
* ERC-7984 token contract addresses to delegate for.
* One or more addresses — always encoded as ACL.multicall([delegateForUserDecryption x N]).
* Pass a single address for single-token delegation; the multicall wrapper is always used
* for a consistent transaction structure regardless of token count.
*/
tokenContractAddresses: string[];

/**
* Delegation expiry as a Unix timestamp (seconds).
* Recommended: Math.floor(Date.now() / 1000) + 365 * 86400 (1 year)
*/
expiryTimestamp: number;

/**
* Optional forwarder contract address.
*
* When set, the delegation calldata is wrapped in a
* ForwarderV4.callFromParent(aclContractAddress, 0, delegationCalldata) call,
* so that the forwarder itself becomes msg.sender (and therefore the delegator)
* in the ACL call.
*
* Only the parentAddress (root wallet) may call callFromParent —
* this is enforced by the forwarder's onlyParent modifier.
*
* Leave undefined when the root wallet is delegating directly.
*/
forwarderAddress?: string;
}

/**
* The wallet-type-agnostic output of DecryptionDelegationBuilder.build().
*
* WP is responsible for routing this to the correct signing path:
* - MPC (TSS): submit as a raw transaction {to, data, value=0}
* - Multisig root: sendMultiSig(walletContract, to, 0, data, expiry, seqId, sig)
* - Multisig forwarder: sendMultiSig(walletContract, forwarder, 0, callFromParentData, expiry, seqId, sig)
*/
export interface DecryptionDelegationTxRequest {
/**
* Transaction recipient:
* - ACL contract address when delegating from root wallet directly
* - Forwarder address when wrapping in callFromParent
*/
to: string;

/** ABI-encoded calldata for the decryption delegation operation. */
data: string;

/** Always '0' — decryption delegation transactions carry no ETH value. */
value: string;
}

/**
* Builder for Zama ERC-7984 ACL decryption delegation transactions.
*
* Grants BitGo's enterprise viewing key the right to decrypt ERC-7984 token
* balances on behalf of the wallet owner via ACL.delegateForUserDecryption().
*
* Produces a DecryptionDelegationTxRequest that works for both MPC and multisig
* wallets. Always uses ACL.multicall() regardless of token count, giving WP a
* consistent transaction structure to handle.
*
* Two scenarios:
* 1. Root wallet → ACL.multicall([delegateForUserDecryption x N]) sent directly to ACL
* 2. Forwarder → callFromParent(ACL, 0, multicall([...])) sent to forwarder contract
*
* Usage:
* const req = new DecryptionDelegationBuilder().build({
* aclContractAddress: '0xf0Ff...',
* delegateAddress: enterpriseViewingKey,
* tokenContractAddresses: [tokenAddress], // one or more tokens
* expiryTimestamp: Math.floor(Date.now() / 1000) + 365 * 86400,
* });
*/
export class DecryptionDelegationBuilder {
/**
* Build the decryption delegation transaction request.
*
* @param params Decryption delegation parameters
* @returns DecryptionDelegationTxRequest containing {to, data, value} ready for WP signing
* @throws Error if tokenContractAddresses is empty
*/
build(params: DecryptionDelegationBuilderParams): DecryptionDelegationTxRequest {
const { aclContractAddress, delegateAddress, tokenContractAddresses, expiryTimestamp, forwarderAddress } = params;

if (tokenContractAddresses.length === 0) {
throw new Error('DecryptionDelegationBuilder: tokenContractAddresses must not be empty');
}
Comment thread
MohammedRyaan786 marked this conversation as resolved.

// Always encode as ACL.multicall([delegateForUserDecryption x N]) for a consistent
// transaction structure regardless of whether one or many tokens are delegated.
const innerCalldata = buildMulticallDelegationCalldata(delegateAddress, tokenContractAddresses, expiryTimestamp);

// Optionally wrap in callFromParent for forwarder delegation
if (forwarderAddress !== undefined) {
return {
to: forwarderAddress,
data: wrapInCallFromParent(aclContractAddress, innerCalldata),
value: '0',
};
}

return {
to: aclContractAddress,
data: innerCalldata,
value: '0',
};
}
}
2 changes: 2 additions & 0 deletions modules/abstract-eth/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './constants';
export * from './zamaUtils';
export * from './decryptionDelegationBuilder';
export * from './contractCall';
export * from './iface';
export * from './keyPair';
Expand Down
9 changes: 8 additions & 1 deletion modules/abstract-eth/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
case TransactionType.SingleSigSend:
return this.buildBase('0x');
case TransactionType.ContractCall:
case TransactionType.DecryptionDelegation:
Comment thread
MohammedRyaan786 marked this conversation as resolved.
return this.buildGenericContractCallTransaction();
default:
throw new BuildTransactionError('Unsupported transaction type');
Expand Down Expand Up @@ -295,6 +296,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
this.setContract(transactionJson.to);
break;
case TransactionType.ContractCall:
case TransactionType.DecryptionDelegation:
this.setContract(transactionJson.to);
this.data(transactionJson.data);
break;
Expand Down Expand Up @@ -444,6 +446,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
case TransactionType.StakingWithdraw:
break;
case TransactionType.ContractCall:
case TransactionType.DecryptionDelegation:
this.validateContractAddress();
this.validateDataField();
break;
Expand Down Expand Up @@ -863,7 +866,11 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {

// region generic contract call
data(encodedCall: string): void {
const supportedTransactionTypes = [TransactionType.ContractCall, TransactionType.RecoveryWalletDeployment];
const supportedTransactionTypes = [
TransactionType.ContractCall,
TransactionType.RecoveryWalletDeployment,
TransactionType.DecryptionDelegation,
];
if (!supportedTransactionTypes.includes(this._type)) {
throw new BuildTransactionError('data can only be set for contract call transaction types');
}
Expand Down
8 changes: 8 additions & 0 deletions modules/abstract-eth/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import {
sendMultiSigTypesFirstSigner,
} from './walletUtil';
import { EthTransactionData } from './types';
import { delegateForUserDecryptionMethodId } from './zamaUtils';

/**
* @param network
Expand Down Expand Up @@ -727,6 +728,13 @@ const transactionTypesMap = {
[UnvoteMethodId]: TransactionType.StakingUnvote,
[UnlockMethodId]: TransactionType.StakingUnlock,
[WithdrawMethodId]: TransactionType.StakingWithdraw,
// aclMulticallMethodId (multicall(bytes[])) is intentionally NOT mapped here.
// classifyTransaction() only sees calldata, not `to`, so 0xac9650d8 would mislabel
// any OpenZeppelin MulticallUpgradeable call (routers, aggregators, unrelated contracts)
// as DecryptionDelegation. Builder output (which always uses multicall) therefore
// classifies as ContractCall; callers should set TransactionType.DecryptionDelegation
// explicitly when building from a known delegation template.
[delegateForUserDecryptionMethodId]: TransactionType.DecryptionDelegation,
};

/**
Expand Down
129 changes: 129 additions & 0 deletions modules/abstract-eth/src/lib/zamaUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { addHexPrefix, toBuffer } from 'ethereumjs-util';
import EthereumAbi from 'ethereumjs-abi';

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

// ABI parameter type arrays
export const delegateForUserDecryptionTypes = ['address', 'address', 'uint64'] as const;
export const callFromParentTypes = ['address', 'uint256', 'bytes'] as const;
export const aclMulticallTypes = ['bytes[]'] as const;

/**
* Function selector for ACL.delegateForUserDecryption(address,address,uint64)
* = keccak256('delegateForUserDecryption(address,address,uint64)')[0:4]
*/
export const delegateForUserDecryptionMethodId = addHexPrefix(
EthereumAbi.methodID('delegateForUserDecryption', [...delegateForUserDecryptionTypes]).toString('hex')
);

/**
* Function selector for ACL.multicall(bytes[])
* = keccak256('multicall(bytes[])')[0:4]
* ACL inherits OpenZeppelin MulticallUpgradeable — preserves msg.sender via delegatecall.
*/
export const aclMulticallMethodId = addHexPrefix(
EthereumAbi.methodID('multicall', [...aclMulticallTypes]).toString('hex')
);

/**
* Function selector for ForwarderV4.callFromParent(address,uint256,bytes)
* = keccak256('callFromParent(address,uint256,bytes)')[0:4]
*/
export const callFromParentMethodId = addHexPrefix(
EthereumAbi.methodID('callFromParent', [...callFromParentTypes]).toString('hex')
);

// ---------------------------------------------------------------------------
// Encoding functions
// ---------------------------------------------------------------------------

/**
* Encodes a single ACL.delegateForUserDecryption() call.
*
* Grants `delegateAddress` the right to decrypt ERC-7984 token balances on
* behalf of the calling address (msg.sender) for the specified token contract.
*
* @param delegateAddress BitGo enterprise viewing key address
* @param tokenContractAddress ERC-7984 token contract address
* @param expiryTimestamp Unix seconds; recommended: Math.floor(Date.now()/1000) + 365*86400
* @returns ABI-encoded calldata hex string (0x-prefixed)
*/
export function buildDelegationCalldata(
delegateAddress: string,
tokenContractAddress: string,
expiryTimestamp: number
): string {
const method = EthereumAbi.methodID('delegateForUserDecryption', [...delegateForUserDecryptionTypes]);
const args = EthereumAbi.rawEncode(
[...delegateForUserDecryptionTypes],
[delegateAddress, tokenContractAddress, expiryTimestamp]
);
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
}

/**
* Encodes N delegateForUserDecryption calls batched inside ACL.multicall().
*
* Produces a single TX that grants delegation for all specified token contracts.
* Requires tokenContractAddresses.length >= 1.
* Note: DecryptionDelegationBuilder always uses this function (even for a single token)
* to keep the transaction shape consistent regardless of token count.
*
* @param delegateAddress BitGo enterprise viewing key address
* @param tokenContractAddresses Array of ERC-7984 token contract addresses
* @param expiryTimestamp Unix seconds
* @returns ABI-encoded calldata hex string (0x-prefixed)
*/
export function buildMulticallDelegationCalldata(
delegateAddress: string,
tokenContractAddresses: string[],
expiryTimestamp: number
): string {
if (tokenContractAddresses.length === 0) {
throw new Error('buildMulticallDelegationCalldata: tokenContractAddresses must not be empty');
}

// Build each inner delegateForUserDecryption call as raw bytes
const innerCalls: Buffer[] = tokenContractAddresses.map((tokenAddress) => {
const innerMethod = EthereumAbi.methodID('delegateForUserDecryption', [...delegateForUserDecryptionTypes]);
const innerArgs = EthereumAbi.rawEncode(
[...delegateForUserDecryptionTypes],
[delegateAddress, tokenAddress, expiryTimestamp]
);
return Buffer.concat([innerMethod, innerArgs]);
});
Comment thread
MohammedRyaan786 marked this conversation as resolved.

// Encode outer multicall(bytes[])
const outerMethod = EthereumAbi.methodID('multicall', [...aclMulticallTypes]);
const outerArgs = EthereumAbi.rawEncode([...aclMulticallTypes], [innerCalls]);
return addHexPrefix(Buffer.concat([outerMethod, outerArgs]).toString('hex'));
}

/**
* Wraps calldata in a ForwarderV4.callFromParent(target, 0, data) call.
*
* Used when a forwarder contract must be msg.sender for an external contract
* call — for example, when the forwarder itself needs to call
* ACL.delegateForUserDecryption() so that its own balance can be decrypted.
*
* Only the parentAddress (root wallet) is allowed to call callFromParent
* (enforced by the forwarder's onlyParent modifier).
*
* @param targetAddress Address of the contract the forwarder will call (e.g. ACL)
* @param calldata ABI-encoded inner calldata (e.g. from buildDelegationCalldata)
* @returns ABI-encoded callFromParent calldata hex string (0x-prefixed)
*/
export function wrapInCallFromParent(targetAddress: string, calldata: string): string {
const method = EthereumAbi.methodID('callFromParent', [...callFromParentTypes]);
const args = EthereumAbi.rawEncode(
[...callFromParentTypes],
[
targetAddress,
0, // value: no ETH transfer
toBuffer(calldata), // inner calldata as bytes
]
);
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
}
Loading