Skip to content

Commit 88f9261

Browse files
feat: add decryption delegation support for zama
TICKET: CHALO-434
1 parent 9001be2 commit 88f9261

11 files changed

Lines changed: 978 additions & 13 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: 6 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');
@@ -863,7 +864,11 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
863864

864865
// region generic contract call
865866
data(encodedCall: string): void {
866-
const supportedTransactionTypes = [TransactionType.ContractCall, TransactionType.RecoveryWalletDeployment];
867+
const supportedTransactionTypes = [
868+
TransactionType.ContractCall,
869+
TransactionType.RecoveryWalletDeployment,
870+
TransactionType.DecryptionDelegation,
871+
];
867872
if (!supportedTransactionTypes.includes(this._type)) {
868873
throw new BuildTransactionError('data can only be set for contract call transaction types');
869874
}

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

Lines changed: 2 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,7 @@ const transactionTypesMap = {
727728
[UnvoteMethodId]: TransactionType.StakingUnvote,
728729
[UnlockMethodId]: TransactionType.StakingUnlock,
729730
[WithdrawMethodId]: TransactionType.StakingWithdraw,
731+
[delegateForUserDecryptionMethodId]: TransactionType.DecryptionDelegation,
730732
};
731733

732734
/**
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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. For a single token, prefer
71+
* buildDelegationCalldata() directly.
72+
*
73+
* @param delegateAddress BitGo enterprise viewing key address
74+
* @param tokenContractAddresses Array of ERC-7984 token contract addresses
75+
* @param expiryTimestamp Unix seconds
76+
* @returns ABI-encoded calldata hex string (0x-prefixed)
77+
*/
78+
export function buildMulticallDelegationCalldata(
79+
delegateAddress: string,
80+
tokenContractAddresses: string[],
81+
expiryTimestamp: number
82+
): string {
83+
if (tokenContractAddresses.length === 0) {
84+
throw new Error('buildMulticallDelegationCalldata: tokenContractAddresses must not be empty');
85+
}
86+
87+
// Build each inner delegateForUserDecryption call as raw bytes
88+
const innerCalls: Buffer[] = tokenContractAddresses.map((tokenAddress) => {
89+
const innerMethod = EthereumAbi.methodID('delegateForUserDecryption', [...delegateForUserDecryptionTypes]);
90+
const innerArgs = EthereumAbi.rawEncode(
91+
[...delegateForUserDecryptionTypes],
92+
[delegateAddress, tokenAddress, expiryTimestamp]
93+
);
94+
return Buffer.concat([innerMethod, innerArgs]);
95+
});
96+
97+
// Encode outer multicall(bytes[])
98+
const outerMethod = EthereumAbi.methodID('multicall', [...aclMulticallTypes]);
99+
const outerArgs = EthereumAbi.rawEncode([...aclMulticallTypes], [innerCalls]);
100+
return addHexPrefix(Buffer.concat([outerMethod, outerArgs]).toString('hex'));
101+
}
102+
103+
/**
104+
* Wraps calldata in a ForwarderV4.callFromParent(target, 0, data) call.
105+
*
106+
* Used when a forwarder contract must be msg.sender for an external contract
107+
* call — for example, when the forwarder itself needs to call
108+
* ACL.delegateForUserDecryption() so that its own balance can be decrypted.
109+
*
110+
* Only the parentAddress (root wallet) is allowed to call callFromParent
111+
* (enforced by the forwarder's onlyParent modifier).
112+
*
113+
* @param targetAddress Address of the contract the forwarder will call (e.g. ACL)
114+
* @param calldata ABI-encoded inner calldata (e.g. from buildDelegationCalldata)
115+
* @returns ABI-encoded callFromParent calldata hex string (0x-prefixed)
116+
*/
117+
export function wrapInCallFromParent(targetAddress: string, calldata: string): string {
118+
const method = EthereumAbi.methodID('callFromParent', [...callFromParentTypes]);
119+
const args = EthereumAbi.rawEncode(
120+
[...callFromParentTypes],
121+
[
122+
targetAddress,
123+
0, // value: no ETH transfer
124+
toBuffer(calldata), // inner calldata as bytes
125+
]
126+
);
127+
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
128+
}

0 commit comments

Comments
 (0)