Skip to content

Commit d697e42

Browse files
Merge pull request #8765 from BitGo/CHALO-434
feat: add decryption delegation support for zama
2 parents 2dba4cc + ac54106 commit d697e42

14 files changed

Lines changed: 1341 additions & 14 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { buildMulticallDelegationCalldata, wrapInCallFromParent } from './zamaUtils';
2+
3+
/**
4+
* Parameters for building a Zama ERC-7984 decryption delegation transaction.
5+
*/
6+
export interface DecryptionDelegationBuilderParams {
7+
/** Address of the Zama ACL contract on the target network. */
8+
aclContractAddress: string;
9+
10+
/**
11+
* BitGo enterprise viewing key address that receives decryption rights.
12+
*/
13+
delegateAddress: string;
14+
15+
/**
16+
* ERC-7984 token contract addresses to delegate for.
17+
* One or more addresses — always encoded as ACL.multicall([delegateForUserDecryption x N]).
18+
* Pass a single address for single-token delegation; the multicall wrapper is always used
19+
* for a consistent transaction structure regardless of token count.
20+
*/
21+
tokenContractAddresses: string[];
22+
23+
/**
24+
* Delegation expiry as a Unix timestamp (seconds).
25+
* Recommended: Math.floor(Date.now() / 1000) + 365 * 86400 (1 year)
26+
*/
27+
expiryTimestamp: number;
28+
29+
/**
30+
* Optional forwarder contract address.
31+
*
32+
* When set, the delegation calldata is wrapped in a
33+
* ForwarderV4.callFromParent(aclContractAddress, 0, delegationCalldata) call,
34+
* so that the forwarder itself becomes msg.sender (and therefore the delegator)
35+
* in the ACL call.
36+
*
37+
* Only the parentAddress (root wallet) may call callFromParent —
38+
* this is enforced by the forwarder's onlyParent modifier.
39+
*
40+
* Leave undefined when the root wallet is delegating directly.
41+
*/
42+
forwarderAddress?: string;
43+
}
44+
45+
/**
46+
* The wallet-type-agnostic output of DecryptionDelegationBuilder.build().
47+
*
48+
* WP is responsible for routing this to the correct signing path:
49+
* - MPC (TSS): submit as a raw transaction {to, data, value=0}
50+
* - Multisig root: sendMultiSig(walletContract, to, 0, data, expiry, seqId, sig)
51+
* - Multisig forwarder: sendMultiSig(walletContract, forwarder, 0, callFromParentData, expiry, seqId, sig)
52+
*/
53+
export interface DecryptionDelegationTxRequest {
54+
/**
55+
* Transaction recipient:
56+
* - ACL contract address when delegating from root wallet directly
57+
* - Forwarder address when wrapping in callFromParent
58+
*/
59+
to: string;
60+
61+
/** ABI-encoded calldata for the decryption delegation operation. */
62+
data: string;
63+
64+
/** Always '0' — decryption delegation transactions carry no ETH value. */
65+
value: string;
66+
}
67+
68+
/**
69+
* Builder for Zama ERC-7984 ACL decryption delegation transactions.
70+
*
71+
* Grants BitGo's enterprise viewing key the right to decrypt ERC-7984 token
72+
* balances on behalf of the wallet owner via ACL.delegateForUserDecryption().
73+
*
74+
* Produces a DecryptionDelegationTxRequest that works for both MPC and multisig
75+
* wallets. Always uses ACL.multicall() regardless of token count, giving WP a
76+
* consistent transaction structure to handle.
77+
*
78+
* Two scenarios:
79+
* 1. Root wallet → ACL.multicall([delegateForUserDecryption x N]) sent directly to ACL
80+
* 2. Forwarder → callFromParent(ACL, 0, multicall([...])) sent to forwarder contract
81+
*
82+
* Usage:
83+
* const req = new DecryptionDelegationBuilder().build({
84+
* aclContractAddress: '0xf0Ff...',
85+
* delegateAddress: enterpriseViewingKey,
86+
* tokenContractAddresses: [tokenAddress], // one or more tokens
87+
* expiryTimestamp: Math.floor(Date.now() / 1000) + 365 * 86400,
88+
* });
89+
*/
90+
export class DecryptionDelegationBuilder {
91+
/**
92+
* Build the decryption delegation transaction request.
93+
*
94+
* @param params Decryption delegation parameters
95+
* @returns DecryptionDelegationTxRequest containing {to, data, value} ready for WP signing
96+
* @throws Error if tokenContractAddresses is empty
97+
*/
98+
build(params: DecryptionDelegationBuilderParams): DecryptionDelegationTxRequest {
99+
const { aclContractAddress, delegateAddress, tokenContractAddresses, expiryTimestamp, forwarderAddress } = params;
100+
101+
if (tokenContractAddresses.length === 0) {
102+
throw new Error('DecryptionDelegationBuilder: tokenContractAddresses must not be empty');
103+
}
104+
105+
// Always encode as ACL.multicall([delegateForUserDecryption x N]) for a consistent
106+
// transaction structure regardless of whether one or many tokens are delegated.
107+
const innerCalldata = buildMulticallDelegationCalldata(delegateAddress, tokenContractAddresses, expiryTimestamp);
108+
109+
// Optionally wrap in callFromParent for forwarder delegation
110+
if (forwarderAddress !== undefined) {
111+
return {
112+
to: forwarderAddress,
113+
data: wrapInCallFromParent(aclContractAddress, innerCalldata),
114+
value: '0',
115+
};
116+
}
117+
118+
return {
119+
to: aclContractAddress,
120+
data: innerCalldata,
121+
value: '0',
122+
};
123+
}
124+
}

modules/abstract-eth/src/lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export * from './constants';
2+
export * from './zamaUtils';
3+
export * from './decryptionDelegationBuilder';
24
export * from './contractCall';
35
export * from './iface';
46
export * from './keyPair';

modules/abstract-eth/src/lib/transactionBuilder.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
157157
case TransactionType.SingleSigSend:
158158
return this.buildBase('0x');
159159
case TransactionType.ContractCall:
160+
case TransactionType.DecryptionDelegation:
160161
return this.buildGenericContractCallTransaction();
161162
default:
162163
throw new BuildTransactionError('Unsupported transaction type');
@@ -295,6 +296,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
295296
this.setContract(transactionJson.to);
296297
break;
297298
case TransactionType.ContractCall:
299+
case TransactionType.DecryptionDelegation:
298300
this.setContract(transactionJson.to);
299301
this.data(transactionJson.data);
300302
break;
@@ -444,6 +446,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
444446
case TransactionType.StakingWithdraw:
445447
break;
446448
case TransactionType.ContractCall:
449+
case TransactionType.DecryptionDelegation:
447450
this.validateContractAddress();
448451
this.validateDataField();
449452
break;
@@ -863,7 +866,11 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
863866

864867
// region generic contract call
865868
data(encodedCall: string): void {
866-
const supportedTransactionTypes = [TransactionType.ContractCall, TransactionType.RecoveryWalletDeployment];
869+
const supportedTransactionTypes = [
870+
TransactionType.ContractCall,
871+
TransactionType.RecoveryWalletDeployment,
872+
TransactionType.DecryptionDelegation,
873+
];
867874
if (!supportedTransactionTypes.includes(this._type)) {
868875
throw new BuildTransactionError('data can only be set for contract call transaction types');
869876
}

modules/abstract-eth/src/lib/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import {
8585
sendMultiSigTypesFirstSigner,
8686
} from './walletUtil';
8787
import { EthTransactionData } from './types';
88+
import { delegateForUserDecryptionMethodId } from './zamaUtils';
8889

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

732740
/**
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { addHexPrefix, toBuffer } from 'ethereumjs-util';
2+
import EthereumAbi from 'ethereumjs-abi';
3+
4+
// ---------------------------------------------------------------------------
5+
// Constants
6+
// ---------------------------------------------------------------------------
7+
8+
// ABI parameter type arrays
9+
export const delegateForUserDecryptionTypes = ['address', 'address', 'uint64'] as const;
10+
export const callFromParentTypes = ['address', 'uint256', 'bytes'] as const;
11+
export const aclMulticallTypes = ['bytes[]'] as const;
12+
13+
/**
14+
* Function selector for ACL.delegateForUserDecryption(address,address,uint64)
15+
* = keccak256('delegateForUserDecryption(address,address,uint64)')[0:4]
16+
*/
17+
export const delegateForUserDecryptionMethodId = addHexPrefix(
18+
EthereumAbi.methodID('delegateForUserDecryption', [...delegateForUserDecryptionTypes]).toString('hex')
19+
);
20+
21+
/**
22+
* Function selector for ACL.multicall(bytes[])
23+
* = keccak256('multicall(bytes[])')[0:4]
24+
* ACL inherits OpenZeppelin MulticallUpgradeable — preserves msg.sender via delegatecall.
25+
*/
26+
export const aclMulticallMethodId = addHexPrefix(
27+
EthereumAbi.methodID('multicall', [...aclMulticallTypes]).toString('hex')
28+
);
29+
30+
/**
31+
* Function selector for ForwarderV4.callFromParent(address,uint256,bytes)
32+
* = keccak256('callFromParent(address,uint256,bytes)')[0:4]
33+
*/
34+
export const callFromParentMethodId = addHexPrefix(
35+
EthereumAbi.methodID('callFromParent', [...callFromParentTypes]).toString('hex')
36+
);
37+
38+
// ---------------------------------------------------------------------------
39+
// Encoding functions
40+
// ---------------------------------------------------------------------------
41+
42+
/**
43+
* Encodes a single ACL.delegateForUserDecryption() call.
44+
*
45+
* Grants `delegateAddress` the right to decrypt ERC-7984 token balances on
46+
* behalf of the calling address (msg.sender) for the specified token contract.
47+
*
48+
* @param delegateAddress BitGo enterprise viewing key address
49+
* @param tokenContractAddress ERC-7984 token contract address
50+
* @param expiryTimestamp Unix seconds; recommended: Math.floor(Date.now()/1000) + 365*86400
51+
* @returns ABI-encoded calldata hex string (0x-prefixed)
52+
*/
53+
export function buildDelegationCalldata(
54+
delegateAddress: string,
55+
tokenContractAddress: string,
56+
expiryTimestamp: number
57+
): string {
58+
const method = EthereumAbi.methodID('delegateForUserDecryption', [...delegateForUserDecryptionTypes]);
59+
const args = EthereumAbi.rawEncode(
60+
[...delegateForUserDecryptionTypes],
61+
[delegateAddress, tokenContractAddress, expiryTimestamp]
62+
);
63+
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
64+
}
65+
66+
/**
67+
* Encodes N delegateForUserDecryption calls batched inside ACL.multicall().
68+
*
69+
* Produces a single TX that grants delegation for all specified token contracts.
70+
* Requires tokenContractAddresses.length >= 1.
71+
* Note: DecryptionDelegationBuilder always uses this function (even for a single token)
72+
* to keep the transaction shape consistent regardless of token count.
73+
*
74+
* @param delegateAddress BitGo enterprise viewing key address
75+
* @param tokenContractAddresses Array of ERC-7984 token contract addresses
76+
* @param expiryTimestamp Unix seconds
77+
* @returns ABI-encoded calldata hex string (0x-prefixed)
78+
*/
79+
export function buildMulticallDelegationCalldata(
80+
delegateAddress: string,
81+
tokenContractAddresses: string[],
82+
expiryTimestamp: number
83+
): string {
84+
if (tokenContractAddresses.length === 0) {
85+
throw new Error('buildMulticallDelegationCalldata: tokenContractAddresses must not be empty');
86+
}
87+
88+
// Build each inner delegateForUserDecryption call as raw bytes
89+
const innerCalls: Buffer[] = tokenContractAddresses.map((tokenAddress) => {
90+
const innerMethod = EthereumAbi.methodID('delegateForUserDecryption', [...delegateForUserDecryptionTypes]);
91+
const innerArgs = EthereumAbi.rawEncode(
92+
[...delegateForUserDecryptionTypes],
93+
[delegateAddress, tokenAddress, expiryTimestamp]
94+
);
95+
return Buffer.concat([innerMethod, innerArgs]);
96+
});
97+
98+
// Encode outer multicall(bytes[])
99+
const outerMethod = EthereumAbi.methodID('multicall', [...aclMulticallTypes]);
100+
const outerArgs = EthereumAbi.rawEncode([...aclMulticallTypes], [innerCalls]);
101+
return addHexPrefix(Buffer.concat([outerMethod, outerArgs]).toString('hex'));
102+
}
103+
104+
/**
105+
* Wraps calldata in a ForwarderV4.callFromParent(target, 0, data) call.
106+
*
107+
* Used when a forwarder contract must be msg.sender for an external contract
108+
* call — for example, when the forwarder itself needs to call
109+
* ACL.delegateForUserDecryption() so that its own balance can be decrypted.
110+
*
111+
* Only the parentAddress (root wallet) is allowed to call callFromParent
112+
* (enforced by the forwarder's onlyParent modifier).
113+
*
114+
* @param targetAddress Address of the contract the forwarder will call (e.g. ACL)
115+
* @param calldata ABI-encoded inner calldata (e.g. from buildDelegationCalldata)
116+
* @returns ABI-encoded callFromParent calldata hex string (0x-prefixed)
117+
*/
118+
export function wrapInCallFromParent(targetAddress: string, calldata: string): string {
119+
const method = EthereumAbi.methodID('callFromParent', [...callFromParentTypes]);
120+
const args = EthereumAbi.rawEncode(
121+
[...callFromParentTypes],
122+
[
123+
targetAddress,
124+
0, // value: no ETH transfer
125+
toBuffer(calldata), // inner calldata as bytes
126+
]
127+
);
128+
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
129+
}

0 commit comments

Comments
 (0)