Skip to content

Commit 1257bb5

Browse files
Merge pull request #8839 from BitGo/CHALO-472
feat(sdk-coin-eth): implement enableToken flow for ERC-7984 confident…
2 parents a28a30c + df1c66b commit 1257bb5

3 files changed

Lines changed: 662 additions & 3 deletions

File tree

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,57 @@ export function wrapInCallFromParent(targetAddress: string, calldata: string): s
134134
);
135135
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
136136
}
137+
138+
/**
139+
* Decodes token contract addresses from delegation calldata.
140+
*
141+
* Handles two shapes of calldata:
142+
* - Direct ACL.multicall(bytes[]) (root wallet path)
143+
* - ForwarderV4.callFromParent(address, uint256, bytes) wrapping a multicall (forwarder path)
144+
*
145+
* @param calldata ABI-encoded delegation calldata (0x-prefixed or raw hex)
146+
* @returns Array of token contract addresses (lowercase) found in the delegation calls
147+
* @throws {Error} if the calldata does not start with a recognised method selector
148+
*/
149+
export function decodeTokenAddressesFromDelegationCalldata(calldata: string): string[] {
150+
const data = calldata.startsWith('0x') ? calldata : '0x' + calldata;
151+
const methodId = data.slice(0, 10);
152+
const abiCoder = new ethers.utils.AbiCoder();
153+
154+
let multicallHex: string;
155+
156+
if (methodId === callFromParentMethodId) {
157+
// Decode callFromParent(address, uint256, bytes) — inner bytes is the full multicall calldata.
158+
// ethers v5 returns `bytes` as a hex string; use hexlify to normalise to a 0x-prefixed hex string
159+
// regardless of whether the runtime returns a string or a Uint8Array.
160+
const decoded = abiCoder.decode([...callFromParentTypes], '0x' + data.slice(10));
161+
multicallHex = ethers.utils.hexlify(decoded[2]);
162+
} else if (methodId === aclMulticallMethodId) {
163+
multicallHex = data;
164+
} else {
165+
throw new Error('Not a valid delegation calldata');
166+
}
167+
168+
if (multicallHex.slice(0, 10) !== aclMulticallMethodId) {
169+
throw new Error('Not a valid delegation calldata');
170+
}
171+
172+
// Decode multicall(bytes[]) — each element is an inner delegateForUserDecryption call.
173+
// ethers v5 returns bytes[] elements as hex strings; use hexlify to normalise each element.
174+
const decoded = abiCoder.decode(['bytes[]'], '0x' + multicallHex.slice(10));
175+
const innerCalls: unknown[] = decoded[0];
176+
177+
const tokenAddresses: string[] = [];
178+
for (const innerCall of innerCalls) {
179+
const innerHex = ethers.utils.hexlify(innerCall as ethers.utils.BytesLike).slice(2); // strip 0x
180+
const innerMethodId = '0x' + innerHex.slice(0, 8);
181+
if (innerMethodId !== delegateForUserDecryptionMethodId) {
182+
continue;
183+
}
184+
// Decode delegateForUserDecryption(address delegate, address tokenAddress, uint64 expiry)
185+
const innerDecoded = abiCoder.decode([...delegateForUserDecryptionTypes], '0x' + innerHex.slice(8));
186+
tokenAddresses.push((innerDecoded[1] as string).toLowerCase());
187+
}
188+
189+
return tokenAddresses;
190+
}

modules/sdk-coin-eth/src/erc7984Token.ts

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
/**
22
* @prettier
33
*/
4-
import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor } from '@bitgo/sdk-core';
4+
import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor, TokenEnablementConfig } from '@bitgo/sdk-core';
55

6-
import { coins, Erc7984TokenConfig, tokens } from '@bitgo/statics';
7-
import { CoinNames, DecryptionDelegationBuilder } from '@bitgo/abstract-eth';
6+
import { coins, Erc7984TokenConfig, EthereumNetwork, tokens } from '@bitgo/statics';
7+
import {
8+
CoinNames,
9+
DecryptionDelegationBuilder,
10+
decodeTokenAddressesFromDelegationCalldata,
11+
VerifyEthTransactionOptions,
12+
aclMulticallMethodId,
13+
callFromParentMethodId,
14+
} from '@bitgo/abstract-eth';
815

916
import { Eth } from './eth';
1017
import { TransactionBuilder } from './lib';
@@ -114,6 +121,148 @@ export class Erc7984Token extends Eth {
114121
return new TransactionBuilder(coins.get(this.getBaseChain()));
115122
}
116123

124+
/** @inheritDoc */
125+
getTokenEnablementConfig(): TokenEnablementConfig {
126+
return {
127+
requiresTokenEnablement: true,
128+
supportsMultipleTokenEnablements: true,
129+
};
130+
}
131+
132+
/** @inheritDoc */
133+
async verifyTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
134+
if (params.txParams?.type === 'enabletoken') {
135+
return this.verifyEnableTokenTransaction(params);
136+
}
137+
return super.verifyTransaction(params);
138+
}
139+
140+
/**
141+
* Verifies a token enablement transaction for ERC-7984 decryption delegation.
142+
*
143+
* TSS path: decodes the raw tx and verifies it calls the ACL contract with
144+
* calldata that covers all requested token contract addresses.
145+
*
146+
* Multisig path: verifies the buildParams recipients carry the correct tokenNames
147+
* and zero amounts.
148+
*/
149+
private async verifyEnableTokenTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
150+
const { txParams, txPrebuild, walletType } = params;
151+
152+
if (walletType === 'tss') {
153+
// TSS path: full raw-tx decode
154+
const enableTokens = txParams.enableTokens;
155+
if (!enableTokens || enableTokens.length === 0) {
156+
throw new Error('verifyEnableTokenTransaction: enableTokens must be non-empty for TSS path');
157+
}
158+
if (!txPrebuild.txHex) {
159+
throw new Error('verifyEnableTokenTransaction: missing txHex in txPrebuild');
160+
}
161+
162+
// Resolve requested token names → contract addresses
163+
const requestedAddresses = enableTokens.map((t) => {
164+
const tokenCoin = this.bitgo.coin(t.name) as Erc7984Token;
165+
return tokenCoin.tokenContractAddress.toLowerCase();
166+
});
167+
168+
// Parse the raw transaction
169+
const txBuilder = this.getTransactionBuilder();
170+
txBuilder.from(txPrebuild.txHex);
171+
const tx = await txBuilder.build();
172+
const txJson = tx.toJson();
173+
174+
// Verify transaction targets the correct contract based on calldata shape
175+
const network = this.getNetwork() as EthereumNetwork;
176+
const aclContractAddress = network?.zamaAclContractAddress;
177+
if (!aclContractAddress) {
178+
throw new Error('verifyEnableTokenTransaction: zamaAclContractAddress not configured for this network');
179+
}
180+
if (!txJson.to) {
181+
throw new Error('verifyEnableTokenTransaction: transaction is missing recipient address');
182+
}
183+
184+
// Inspect calldata method ID to distinguish root wallet from forwarder wallet:
185+
// aclMulticallMethodId → root wallet: to = ACL contract directly
186+
// callFromParentMethodId → forwarder wallet: to = forwarder, ACL address is inside calldata
187+
const calldataMethodId = txJson.data.slice(0, 10);
188+
if (calldataMethodId === aclMulticallMethodId) {
189+
// Root wallet (base address): tx calls the ACL contract directly
190+
if (txJson.to.toLowerCase() !== aclContractAddress.toLowerCase()) {
191+
throw new Error(
192+
`verifyEnableTokenTransaction: transaction target ${txJson.to} does not match ACL contract ${aclContractAddress}`
193+
);
194+
}
195+
} else if (calldataMethodId === callFromParentMethodId) {
196+
// Forwarder wallet: tx calls the forwarder, which calls the ACL via callFromParent.
197+
// The forwarder address is wallet-specific and cannot be statically verified here;
198+
// token address correctness is still verified below via calldata decoding.
199+
} else {
200+
throw new Error(
201+
`verifyEnableTokenTransaction: unrecognised calldata method ID ${calldataMethodId}; expected multicall or callFromParent`
202+
);
203+
}
204+
205+
// Verify value is 0
206+
if (txJson.value !== '0') {
207+
throw new Error(`verifyEnableTokenTransaction: expected transaction value 0 but got ${txJson.value}`);
208+
}
209+
210+
// Decode token addresses from calldata and verify all requested tokens are present
211+
const decodedAddresses = decodeTokenAddressesFromDelegationCalldata(txJson.data);
212+
for (const requested of requestedAddresses) {
213+
if (!decodedAddresses.includes(requested)) {
214+
throw new Error(
215+
`verifyEnableTokenTransaction: requested token ${requested} not found in delegation calldata`
216+
);
217+
}
218+
}
219+
220+
return true;
221+
} else {
222+
// Multisig path: buildParams-level check
223+
const recipients = txPrebuild.buildParams?.recipients as
224+
| Array<{ tokenName?: string; amount?: string }>
225+
| undefined;
226+
if (!recipients || recipients.length === 0) {
227+
throw new Error('verifyEnableTokenTransaction: missing buildParams.recipients for multisig path');
228+
}
229+
230+
// Determine requested token names from txParams
231+
const requestedTokenNames: string[] = [];
232+
if (txParams.enableTokens && txParams.enableTokens.length > 0) {
233+
requestedTokenNames.push(...txParams.enableTokens.map((t) => t.name));
234+
} else if (txParams.recipients && txParams.recipients.length > 0) {
235+
requestedTokenNames.push(...txParams.recipients.map((r: any) => r.tokenName).filter(Boolean));
236+
}
237+
238+
// Verify all recipients have tokenName and amount = '0'
239+
for (const recipient of recipients) {
240+
if (!recipient.tokenName) {
241+
throw new Error('verifyEnableTokenTransaction: recipient is missing tokenName in buildParams');
242+
}
243+
if (recipient.amount !== '0') {
244+
throw new Error(
245+
`verifyEnableTokenTransaction: expected amount 0 for token enablement but got ${recipient.amount}`
246+
);
247+
}
248+
}
249+
250+
// Verify requested token names are present in recipients
251+
if (requestedTokenNames.length > 0) {
252+
const recipientTokenNames = recipients.map((r) => r.tokenName);
253+
for (const requested of requestedTokenNames) {
254+
if (!recipientTokenNames.includes(requested)) {
255+
throw new Error(
256+
`verifyEnableTokenTransaction: requested token ${requested} not found in buildParams recipients`
257+
);
258+
}
259+
}
260+
}
261+
262+
return true;
263+
}
264+
}
265+
117266
/**
118267
* Returns a DecryptionDelegationBuilder for constructing Zama ACL decryption
119268
* delegation transactions.

0 commit comments

Comments
 (0)