From f7cb42b3271fc0553819c795aedd07db0ed7f98e Mon Sep 17 00:00:00 2001 From: Matt Grote Date: Fri, 21 Aug 2026 14:31:35 +0200 Subject: [PATCH] feat(7702): let cross-contract calls target a specific ephemeral account --- src/services/railgun/wallets/wallets.ts | 116 +++++++++++++----- .../tx-cross-contract-calls-7702.ts | 51 ++++++-- 2 files changed, 122 insertions(+), 45 deletions(-) diff --git a/src/services/railgun/wallets/wallets.ts b/src/services/railgun/wallets/wallets.ts index 5d1f313..fc3a626 100644 --- a/src/services/railgun/wallets/wallets.ts +++ b/src/services/railgun/wallets/wallets.ts @@ -428,6 +428,7 @@ export const sign7702Request = async ( transactions: (TransactionStructV2 | TransactionStructV3)[], actionData: RelayAdapt7702.ActionDataStruct, mnemonicPassword?: string, + ephemeralIndex?: number, ): Promise<{ authorization: Authorization; signature: string; @@ -435,40 +436,59 @@ export const sign7702Request = async ( }> => { const wallet = fullWalletForID(walletID); const provider = getFallbackProviderForNetwork(networkName); - // Single-source every ephemeral facet on the signer that will actually produce the - // authorization below. getCurrentEphemeralAddress resolves the signer's address (honoring the - // same override/provider precedence as sign7702Request), so the authorization nonce and the - // execute nonce are read from the authorization authority itself. Reading them from - // getCurrentEphemeralWallet instead desyncs when a custom signer provider is set (authority - // != nonce-source), which silently invalidates the on-chain authorization. - const ephemeralAddress = await wallet.getCurrentEphemeralAddress( - encryptionKey, - chainId, - mnemonicPassword, - ); - const nonce = await provider.getTransactionCount(ephemeralAddress, 'latest'); - const executionDetails = await getRelayAdapt7702ExecutionDetails( - provider, - networkName, - ephemeralAddress, - ); - const { authorization, signature } = await wallet.sign7702Request( - encryptionKey, - contractAddress, - chainId, - transactions, - actionData, - nonce, - executionDetails, - mnemonicPassword, - ); + // The engine reads the override ahead of both the stored index and any custom signer provider, + // so pinning it here keeps the authorization authority, the `to` address and the nonce on one + // account. It is restored below: the override is shared state on the wallet. + const previousEphemeralWalletOverride = wallet.ephemeralWalletOverride; + if (isDefined(ephemeralIndex)) { + assertCanonicalEphemeralProvider(wallet); + wallet.ephemeralWalletOverride = await wallet.getEphemeralWallet( + encryptionKey, + chainId, + ephemeralIndex, + mnemonicPassword, + ); + } - return { - authorization, - signature, - executionDetails, - }; + try { + // Single-source every ephemeral facet on the signer that will actually produce the + // authorization below. getCurrentEphemeralAddress resolves the signer's address (honoring the + // same override/provider precedence as sign7702Request), so the authorization nonce and the + // execute nonce are read from the authorization authority itself. Reading them from + // getCurrentEphemeralWallet instead desyncs when a custom signer provider is set (authority + // != nonce-source), which silently invalidates the on-chain authorization. + const ephemeralAddress = await wallet.getCurrentEphemeralAddress( + encryptionKey, + chainId, + mnemonicPassword, + ); + const nonce = await provider.getTransactionCount(ephemeralAddress, 'latest'); + const executionDetails = await getRelayAdapt7702ExecutionDetails( + provider, + networkName, + ephemeralAddress, + ); + + const { authorization, signature } = await wallet.sign7702Request( + encryptionKey, + contractAddress, + chainId, + transactions, + actionData, + nonce, + executionDetails, + mnemonicPassword, + ); + + return { + authorization, + signature, + executionDetails, + }; + } finally { + wallet.ephemeralWalletOverride = previousEphemeralWalletOverride; + } }; export const ratchetEphemeralAddress = async ( @@ -480,6 +500,38 @@ export const ratchetEphemeralAddress = async ( return wallet.ratchetEphemeralAddress(chainId); }; +// A custom ephemeral signer provider (eg. a hardware wallet) owns keys the engine cannot derive +// from an integer index, so pinning an index there would sign with an unrelated mnemonic-derived +// account. Callers that target an index must be on the canonical HD provider. +const assertCanonicalEphemeralProvider = (wallet: RailgunWallet) => { + if (!wallet.isCanonicalEphemeralProvider()) { + throw new Error( + 'Cannot target an ephemeral account by index: this wallet uses a custom ephemeral signer provider.', + ); + } +}; + +// Resolves the address of a specific ephemeral account, so funds left behind on a rotated account +// stay reachable. `getCurrentEphemeralAddress` covers the common case of the account in use now. +export const getEphemeralAddressForIndex = async ( + walletID: string, + encryptionKey: string, + networkName: NetworkName, + ephemeralIndex: number, + mnemonicPassword?: string, +): Promise => { + const wallet = fullWalletForID(walletID); + assertCanonicalEphemeralProvider(wallet); + const chainId = BigInt(NETWORK_CONFIG[networkName].chain.id); + const ephemeralWallet = await wallet.getEphemeralWallet( + encryptionKey, + chainId, + ephemeralIndex, + mnemonicPassword, + ); + return ephemeralWallet.address; +}; + export const getCurrentEphemeralAddress = async ( walletID: string, encryptionKey: string, diff --git a/src/services/transactions/tx-cross-contract-calls-7702.ts b/src/services/transactions/tx-cross-contract-calls-7702.ts index a767ff3..eacb663 100644 --- a/src/services/transactions/tx-cross-contract-calls-7702.ts +++ b/src/services/transactions/tx-cross-contract-calls-7702.ts @@ -12,6 +12,7 @@ import { EVMGasType, TXIDVersion, NETWORK_CONFIG, + isDefined, } from '@railgun-community/shared-models'; import { GenerateTransactionsProgressCallback, @@ -44,7 +45,11 @@ import { ContractTransaction } from 'ethers'; import { createRelayAdaptShieldNFTRecipients, } from './tx-cross-contract-calls'; -import { getCurrentEphemeralAddress, sign7702Request } from '../railgun/wallets/wallets'; +import { + getCurrentEphemeralAddress, + getEphemeralAddressForIndex, + sign7702Request, +} from '../railgun/wallets/wallets'; import { encodeRelayAdapt7702Execute } from '../railgun/wallets/relay-adapt-7702-execution'; @@ -152,6 +157,7 @@ export const gasEstimateForUnprovenCrossContractCalls7702 = async ( sendWithPublicWallet: boolean, minGasLimit: Optional, mnemonicPassword?: string, + ephemeralIndex?: number, ): Promise => { try { setCachedProvedTransaction(undefined); @@ -165,12 +171,20 @@ export const gasEstimateForUnprovenCrossContractCalls7702 = async ( const validCrossContractCalls = createValidCrossContractCalls(crossContractCalls); - const ephemeralAddress = await getCurrentEphemeralAddress( - railgunWalletID, - encryptionKey, - networkName, - mnemonicPassword, - ); + const ephemeralAddress = isDefined(ephemeralIndex) + ? await getEphemeralAddressForIndex( + railgunWalletID, + encryptionKey, + networkName, + ephemeralIndex, + mnemonicPassword, + ) + : await getCurrentEphemeralAddress( + railgunWalletID, + encryptionKey, + networkName, + mnemonicPassword, + ); const relayAdaptUnshieldERC20AmountRecipients = createRelayAdapt7702UnshieldERC20AmountRecipients( @@ -233,6 +247,7 @@ export const gasEstimateForUnprovenCrossContractCalls7702 = async ( transactions, actionData, mnemonicPassword, + ephemeralIndex, ); const data = encodeRelayAdapt7702Execute( @@ -318,6 +333,7 @@ export const generateCrossContractCallsProof7702 = async ( minGasLimit: Optional, progressCallback: GenerateTransactionsProgressCallback, mnemonicPassword?: string, + ephemeralIndex?: number, ): Promise => { try { setCachedProvedTransaction(undefined); @@ -325,12 +341,20 @@ export const generateCrossContractCallsProof7702 = async ( const validCrossContractCalls = createValidCrossContractCalls(crossContractCalls); - const ephemeralAddress = await getCurrentEphemeralAddress( - railgunWalletID, - encryptionKey, - networkName, - mnemonicPassword, - ); + const ephemeralAddress = isDefined(ephemeralIndex) + ? await getEphemeralAddressForIndex( + railgunWalletID, + encryptionKey, + networkName, + ephemeralIndex, + mnemonicPassword, + ) + : await getCurrentEphemeralAddress( + railgunWalletID, + encryptionKey, + networkName, + mnemonicPassword, + ); const relayAdaptUnshieldERC20AmountRecipients = createRelayAdapt7702UnshieldERC20AmountRecipients( @@ -426,6 +450,7 @@ export const generateCrossContractCallsProof7702 = async ( transactions, actionData, mnemonicPassword, + ephemeralIndex, ); // Construct the transaction data