Skip to content

Commit b132bfe

Browse files
committed
fix(sdk-core): allow ERC-7984 PA rebuild without recipients (CHALO-1957)
pendingApproval.approve() on a custodial multi-sig ERC-7984 leg PA (wrapApprove/wrap/unwrap user legs, wrap/finalizeUnwrap server-originated legs) merges the PA's stored info.transactionRequest.recipients into the prebuild params, tripping the prebuildAndSignTransaction no-recipients guard before any network call. recreateAndSignTransaction now strips recipients for the four ERC-7984 intent types before prebuilding: stored recipients are display-only for the approval UI, and the /tx/build PA-rebuild carve-out (CHALO-1954) re-derives the real intent inputs server-side from the pendingApprovalId approve() injects. Other PA types forward recipients unchanged, and the guard still rejects direct sendMany/prebuild calls without a pendingApprovalId. Ticket: CHALO-1957
1 parent d4a0535 commit b132bfe

6 files changed

Lines changed: 786 additions & 2 deletions

File tree

‎modules/abstract-eth/src/abstractEthLikeNewCoins.ts‎

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,11 @@ import {
8686
coinFamiliesWithL1Fees,
8787
approveMethodId,
8888
decodeApproveCalldata,
89+
decodeFinalizeUnwrapCalldata,
8990
decodeNativeTransferData,
9091
decodeTransferData,
92+
decodeUnwrapCalldata,
93+
decodeWrapCalldata,
9194
ERC1155TransferBuilder,
9295
ERC721TransferBuilder,
9396
getBufferedByteCode,
@@ -100,6 +103,7 @@ import {
100103
sendMultisigMethodId,
101104
TransactionBuilder,
102105
TransferBuilder,
106+
UINT64_MAX,
103107
} from './lib';
104108
import { SendCrossChainRecoveryOptions } from './types';
105109

@@ -392,6 +396,8 @@ interface EthTransactionParams extends TransactionParams {
392396
defiParams?: DefiIntentParams;
393397
/** ERC-7984 shield intent (`type: 'wrapApprove' | 'wrap'`): wrapper coin name and exact amount. */
394398
wrapParams?: { tokenName: string; amount: string };
399+
/** ERC-7984 unshield intent (`type: 'unwrap'`): wrapper coin name and plaintext amount. */
400+
unwrapParams?: { tokenName: string; amount: string };
395401
}
396402

397403
export interface VerifyEthTransactionOptions extends VerifyTransactionOptions {
@@ -3747,6 +3753,15 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem
37473753
return true;
37483754
}
37493755

3756+
// ERC-7984 intent legs (wrap / unwrap / finalizeUnwrap) for non-TSS (onchain multisig) wallets:
3757+
// calldata is derived server-side from the intent or the journey, so no client recipients exist.
3758+
// Content-based on the sendMultiSig args instead of the generic recipient guards.
3759+
if (txParams?.type === 'wrap' || txParams?.type === 'unwrap' || txParams?.type === 'finalizeUnwrap') {
3760+
await this.verifyMultisigErc7984IntentTransaction(params, throwRecipientMismatch);
3761+
await this.throwIfPrebuildCoinMismatch(txParams, txPrebuild);
3762+
return true;
3763+
}
3764+
37503765
if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) {
37513766
throw new Error('missing params');
37523767
}
@@ -4077,6 +4092,235 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem
40774092
}
40784093
}
40794094

4095+
/**
4096+
* Verifies an ERC-7984 intent leg (wrap / unwrap / finalizeUnwrap) for non-TSS (onchain multisig) wallets.
4097+
*
4098+
* Multisig shape (server-built unsigned tx):
4099+
* tx.to = wallet contract
4100+
* tx.data = sendMultiSig(wrapper, 0, <intent calldata>, expireTime, sequenceId, signature)
4101+
* txPrebuild.recipients[0] = { address: wrapper, amount: '0', data: intent calldata }
4102+
*
4103+
* Client recipients never exist for these intents: user-leg builds derive the calldata from
4104+
* wrapParams/unwrapParams, and server-leg PA rebuilds re-derive everything from the journey
4105+
* (the PA carries only the type and pendingApprovalId). Verification is therefore content-based
4106+
* on the sendMultiSig args:
4107+
* - unwrap: strict — inner target must be the registered pair's wrapper for unwrapParams.tokenName
4108+
* and the unwrap args must be self-directed (wallet base address).
4109+
* - wrap: strict when wrapParams is present (target wrapper + amount); bounded otherwise —
4110+
* inner target must be a registered ERC-7984 wrapper and the mint recipient the wallet base.
4111+
* - finalizeUnwrap: bounded — registered wrapper target and well-formed request fields.
4112+
*/
4113+
private async verifyMultisigErc7984IntentTransaction(
4114+
params: VerifyEthTransactionOptions,
4115+
throwRecipientMismatch: (message: string, mismatchedRecipients: Recipient[]) => Promise<never>
4116+
): Promise<void> {
4117+
const { txParams, txPrebuild } = params;
4118+
if (!txParams) {
4119+
throw new Error('missing params');
4120+
}
4121+
const type = txParams.type as 'wrap' | 'unwrap' | 'finalizeUnwrap';
4122+
const intentParams = type === 'unwrap' ? txParams.unwrapParams : txParams.wrapParams;
4123+
if (!txPrebuild?.txHex) {
4124+
throw new Error('missing txHex in txPrebuild');
4125+
}
4126+
if (type === 'unwrap' && !intentParams?.tokenName) {
4127+
return throwRecipientMismatch('unwrap transaction requires unwrapParams with tokenName', []);
4128+
}
4129+
4130+
const txBuilder = this.getTransactionBuilder();
4131+
txBuilder.from(txPrebuild.txHex);
4132+
const txJson = (await txBuilder.build()).toJson();
4133+
4134+
let innerTo: string;
4135+
let innerAmount: string;
4136+
let innerData: string;
4137+
const data = txJson.data || '';
4138+
if (!data.toLowerCase().startsWith(sendMultisigMethodId.toLowerCase())) {
4139+
return throwRecipientMismatch(
4140+
`${type} multisig transaction must encode the ${type} call inside sendMultiSig`,
4141+
[]
4142+
);
4143+
}
4144+
4145+
try {
4146+
const decoded = decodeNativeTransferData(data);
4147+
innerTo = decoded.to;
4148+
innerAmount = decoded.amount;
4149+
innerData = decoded.data;
4150+
} catch (e) {
4151+
return throwRecipientMismatch(
4152+
`${type} multisig transaction failed to decode sendMultiSig calldata — ${(e as Error).message}`,
4153+
[]
4154+
);
4155+
}
4156+
4157+
const mismatch = (message: string, address = innerTo, amount = innerAmount): Promise<never> =>
4158+
throwRecipientMismatch(message, [{ address: address || '', amount: amount || '' }]);
4159+
if (innerAmount !== '0') {
4160+
await mismatch(`${type} transaction must not carry native value inside sendMultiSig`);
4161+
}
4162+
4163+
const baseAddress = this.getVerifyWalletBaseAddress(params.wallet);
4164+
if (!baseAddress) {
4165+
throw new Error('missing required coin specific property baseAddress');
4166+
}
4167+
const registeredWrappers = new Set<string>();
4168+
for (const wrappers of getErc7984WrappersByUnderlying().values()) {
4169+
for (const wrapper of wrappers) {
4170+
registeredWrappers.add(wrapper);
4171+
}
4172+
}
4173+
const registeredWrapperFor = (tokenName: string): string | undefined => {
4174+
const staticsCoin = coins.get(tokenName);
4175+
if (!(staticsCoin instanceof Erc7984Coin) || !staticsCoin.contractAddress) {
4176+
return undefined;
4177+
}
4178+
return staticsCoin.contractAddress.toLowerCase();
4179+
};
4180+
4181+
switch (type) {
4182+
case 'wrap': {
4183+
let decoded: { to: string; amount: string };
4184+
try {
4185+
decoded = decodeWrapCalldata(innerData || '');
4186+
} catch (e) {
4187+
return mismatch(
4188+
`wrap transaction must use ERC-7984 wrap(address,uint256) calldata — ${(e as Error).message}`
4189+
);
4190+
}
4191+
if (decoded.to.toLowerCase() !== baseAddress.toLowerCase()) {
4192+
await mismatch('wrap transaction mint recipient must be the wallet base address', decoded.to, decoded.amount);
4193+
}
4194+
if (!/^\d+$/.test(decoded.amount) || BigInt(decoded.amount) <= 0n) {
4195+
await mismatch('wrap transaction amount must be a positive integer', decoded.to, decoded.amount);
4196+
}
4197+
if (intentParams?.tokenName) {
4198+
const wrapper = registeredWrapperFor(intentParams.tokenName);
4199+
if (!wrapper) {
4200+
return throwRecipientMismatch(
4201+
`wrap: '${intentParams.tokenName}' is not a registered ERC-7984 shield pair`,
4202+
[]
4203+
);
4204+
}
4205+
if (innerTo.toLowerCase() !== wrapper) {
4206+
await mismatch('wrap transaction must target the registered wrapper contract');
4207+
}
4208+
if (decoded.amount !== intentParams.amount) {
4209+
await mismatch('wrap transaction amount does not match the requested amount', decoded.to, decoded.amount);
4210+
}
4211+
} else if (!registeredWrappers.has(innerTo.toLowerCase())) {
4212+
await mismatch('wrap transaction must target a registered ERC-7984 wrapper');
4213+
}
4214+
break;
4215+
}
4216+
case 'unwrap': {
4217+
const wrapper = registeredWrapperFor(intentParams!.tokenName);
4218+
if (!wrapper) {
4219+
return throwRecipientMismatch(
4220+
`unwrap: '${intentParams!.tokenName}' is not a registered ERC-7984 shield pair`,
4221+
[]
4222+
);
4223+
}
4224+
if (innerTo.toLowerCase() !== wrapper) {
4225+
await mismatch('unwrap transaction must target the registered wrapper contract');
4226+
}
4227+
let decoded: { from: string; to: string; encryptedAmount: string; inputProof: string };
4228+
try {
4229+
decoded = decodeUnwrapCalldata(innerData || '');
4230+
} catch (e) {
4231+
return mismatch(
4232+
`unwrap transaction must use ERC-7984 unwrap(address,address,bytes32,bytes) calldata — ${
4233+
(e as Error).message
4234+
}`
4235+
);
4236+
}
4237+
if (
4238+
decoded.from.toLowerCase() !== baseAddress.toLowerCase() ||
4239+
decoded.to.toLowerCase() !== baseAddress.toLowerCase()
4240+
) {
4241+
await mismatch('unwrap transaction must be self-directed to the wallet base address');
4242+
}
4243+
if (!decoded.encryptedAmount || /^0x0+$/.test(decoded.encryptedAmount)) {
4244+
await mismatch('unwrap transaction encryptedAmount is missing or empty');
4245+
}
4246+
if (!decoded.inputProof || decoded.inputProof === '0x') {
4247+
await mismatch('unwrap transaction inputProof is missing or empty');
4248+
}
4249+
break;
4250+
}
4251+
case 'finalizeUnwrap': {
4252+
if (!registeredWrappers.has(innerTo.toLowerCase())) {
4253+
await mismatch('finalizeUnwrap transaction must target a registered ERC-7984 wrapper');
4254+
}
4255+
let decoded: { requestId: string; cleartextAmount: string; decryptionProof: string };
4256+
try {
4257+
decoded = decodeFinalizeUnwrapCalldata(innerData || '');
4258+
} catch (e) {
4259+
return mismatch(
4260+
`finalizeUnwrap transaction must use ERC-7984 finalizeUnwrap(bytes32,uint64,bytes) calldata — ${
4261+
(e as Error).message
4262+
}`
4263+
);
4264+
}
4265+
if (!decoded.requestId || /^0x0+$/.test(decoded.requestId)) {
4266+
await mismatch('finalizeUnwrap transaction requestId is missing or empty');
4267+
}
4268+
if (
4269+
!/^\d+$/.test(decoded.cleartextAmount) ||
4270+
BigInt(decoded.cleartextAmount) <= 0n ||
4271+
BigInt(decoded.cleartextAmount) > UINT64_MAX
4272+
) {
4273+
await mismatch(
4274+
'finalizeUnwrap transaction cleartextAmount must be a positive integer within uint64',
4275+
innerTo,
4276+
decoded.cleartextAmount
4277+
);
4278+
}
4279+
if (!decoded.decryptionProof || decoded.decryptionProof === '0x') {
4280+
await mismatch('finalizeUnwrap transaction decryptionProof is missing or empty');
4281+
}
4282+
break;
4283+
}
4284+
default:
4285+
return throwRecipientMismatch(`unsupported ERC-7984 intent type '${type}'`, []);
4286+
}
4287+
4288+
// Cross-check the build recipients the client signs the operation hash against — they must
4289+
// mirror the sendMultiSig args encoded in txHex.
4290+
const prebuildRecipient = txPrebuild.recipients?.[0];
4291+
if (prebuildRecipient) {
4292+
const recipientDetail = [
4293+
{ address: prebuildRecipient.address, amount: prebuildRecipient.amount?.toString() ?? '' },
4294+
];
4295+
if (prebuildRecipient.address?.toLowerCase() !== innerTo.toLowerCase()) {
4296+
await throwRecipientMismatch(
4297+
`${type} prebuild recipient address does not match the sendMultiSig target encoded in txHex`,
4298+
recipientDetail
4299+
);
4300+
}
4301+
let recipientData: string | undefined;
4302+
if ('data' in prebuildRecipient && typeof prebuildRecipient.data === 'string') {
4303+
recipientData = prebuildRecipient.data;
4304+
}
4305+
if (recipientData && stripHexPrefix(recipientData).toLowerCase() !== stripHexPrefix(innerData).toLowerCase()) {
4306+
await throwRecipientMismatch(
4307+
`${type} prebuild recipient data does not match the sendMultiSig calldata encoded in txHex`,
4308+
recipientDetail
4309+
);
4310+
}
4311+
}
4312+
}
4313+
4314+
/** Wallet base (contract) address for onchain multisig verify paths; undefined when absent. */
4315+
private getVerifyWalletBaseAddress(wallet: VerifyEthTransactionOptions['wallet']): string | undefined {
4316+
if (!wallet) {
4317+
return undefined;
4318+
}
4319+
const coinSpecific = typeof wallet.coinSpecific === 'function' ? wallet.coinSpecific() : wallet.coinSpecific;
4320+
const ethCoinSpecific = coinSpecific as { baseAddress?: string; rootAddress?: string } | undefined;
4321+
return ethCoinSpecific?.baseAddress ?? ethCoinSpecific?.rootAddress;
4322+
}
4323+
40804324
private isETHAddress(address: string): boolean {
40814325
return !!address.match(/0x[a-fA-F0-9]{40}/);
40824326
}

0 commit comments

Comments
 (0)