From cb56b5ca15ccb5b08d70db5cff06b43bde89d84b Mon Sep 17 00:00:00 2001 From: HananINouman Date: Tue, 21 Jul 2026 22:05:49 +0300 Subject: [PATCH] fix(safe): use Client provider for reads and Safe gas estimation WalletConnect cannot attach eRPC Basic Auth, so prefer the explicit provider for contract reads/estimateGas and only use the signer to send. Also fix OVM address resolution from deferred ethers listener payloads. Co-authored-by: Cursor --- src/eoa/eoa.ts | 2 + src/eoa/eoaHelpers.ts | 19 ++-- src/index.ts | 20 +++-- src/splits/splitHelpers.ts | 128 +++++++++++++++++++++++---- src/splits/splits.ts | 10 +++ test/eoa/eoa.spec.ts | 12 +-- test/eoa/safeEoaHelpers.spec.ts | 6 +- test/splits/ovmDeployHelpers.spec.ts | 37 ++++++++ test/splits/splits.spec.ts | 5 +- 9 files changed, 200 insertions(+), 39 deletions(-) diff --git a/src/eoa/eoa.ts b/src/eoa/eoa.ts index 35b2f9d..b969606 100644 --- a/src/eoa/eoa.ts +++ b/src/eoa/eoa.ts @@ -111,6 +111,7 @@ export class EOA { requiredFee: validatedPayload.requiredFee, chainId: this.chainId, signer: this.signer, + provider: this.provider, }); } @@ -158,6 +159,7 @@ export class EOA { deposits: validatedPayload.deposits, batchDepositContractAddress: chainConfig.BATCH_DEPOSIT_CONTRACT.address, signer: this.signer, + provider: this.provider, }); } } diff --git a/src/eoa/eoaHelpers.ts b/src/eoa/eoaHelpers.ts index 1626b9f..c346066 100644 --- a/src/eoa/eoaHelpers.ts +++ b/src/eoa/eoaHelpers.ts @@ -1,5 +1,5 @@ import { ETHER_TO_GWEI } from '../constants.js'; -import { type SignerType } from '../types.js'; +import { type SignerType, type ProviderType } from '../types.js'; import { Contract, type JsonRpcSigner } from 'ethers'; import { BatchDepositContract } from '../abi/BatchDeposit.js'; import { SignerRequiredError } from '../errors.js'; @@ -12,13 +12,16 @@ import { // tx.wait() hangs forever (the hash never appears on-chain). Contract-wallet // signers submit unchecked and resolve via the Safe's ExecutionSuccess log. // This is the first dereference on both write flows, so it guards the signer. -const isSafeLikeSigner = async (signer: SignerType): Promise => { +const isSafeLikeSigner = async ( + signer: SignerType, + provider?: ProviderType | null, +): Promise => { if (!signer) { throw new SignerRequiredError('submitEOATransaction'); } return ( 'sendUncheckedTransaction' in signer && - (await isContractWalletSigner(signer)) + (await isContractWalletSigner(signer, provider)) ); }; @@ -33,6 +36,7 @@ export async function submitEOAWithdrawalRequest({ requiredFee, chainId, signer, + provider, }: { pubkey: string; allocation: number; @@ -41,6 +45,7 @@ export async function submitEOAWithdrawalRequest({ requiredFee: string; chainId: number; signer: SignerType; + provider?: ProviderType | null; }): Promise<{ txHash: string | null }> { if (!withdrawalAddress) { throw new Error('No withdrawal address provided'); @@ -52,12 +57,13 @@ export async function submitEOAWithdrawalRequest({ const amountInGwei = BigInt(Math.floor(Number(allocation) * ETHER_TO_GWEI)); const data = `0x${pubkey.slice(2)}${amountInGwei.toString(16).padStart(16, '0')}`; - if (await isSafeLikeSigner(signer)) { + if (await isSafeLikeSigner(signer, provider)) { const txHash = await submitViaContractWalletAndWait({ signer: signer as JsonRpcSigner, to: withdrawalContractAddress, data, value: BigInt(requiredFee), + provider, }); return { txHash }; } @@ -81,6 +87,7 @@ export async function submitEOABatchDeposit({ deposits, batchDepositContractAddress, signer, + provider, }: { deposits: Array<{ pubkey: string; @@ -91,6 +98,7 @@ export async function submitEOABatchDeposit({ }>; batchDepositContractAddress: string; signer: SignerType; + provider?: ProviderType | null; }): Promise<{ txHashes: string[] }> { if (!deposits || deposits.length === 0) { throw new Error('No deposits provided'); @@ -103,7 +111,7 @@ export async function submitEOABatchDeposit({ signer, ); - const useContractWallet = await isSafeLikeSigner(signer); + const useContractWallet = await isSafeLikeSigner(signer, provider); const BATCH_SIZE = 500; const txHashes: string[] = []; @@ -137,6 +145,7 @@ export async function submitEOABatchDeposit({ [depositData], ), value: totalValue, + provider, }); txHashes.push(txHash); continue; diff --git a/src/index.ts b/src/index.ts index 1ae40f4..75f610d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -343,19 +343,19 @@ export class Client extends Base { const checkSplitMainAddress = await isContractAvailable( chainConfig.SPLITMAIN_CONTRACT.address, - this.signer.provider as ProviderType, + this.provider as ProviderType, chainConfig.SPLITMAIN_CONTRACT.bytecode, ); const checkMulticall3Address = await isContractAvailable( chainConfig.MULTICALL3_CONTRACT.address, - this.signer.provider as ProviderType, + this.provider as ProviderType, chainConfig.MULTICALL3_CONTRACT.bytecode, ); const checkOWRFactoryAddress = await isContractAvailable( chainConfig.OWR_FACTORY_CONTRACT.address, - this.signer.provider as ProviderType, + this.provider as ProviderType, chainConfig.OWR_FACTORY_CONTRACT.bytecode, ); @@ -388,11 +388,12 @@ export class Client extends Base { chainId: this.chainId, distributorFee: validatedPayload.distributorFee, controllerAddress: validatedPayload.controllerAddress, + provider: this.provider, }); const isSplitterDeployed = await isContractAvailable( predictedSplitterAddress, - this.signer.provider as ProviderType, + this.provider as ProviderType, ); const { withdrawal_address, fee_recipient_address } = @@ -478,7 +479,7 @@ export class Client extends Base { const checkSplitMainAddress = await isContractAvailable( chainConfig.SPLITMAIN_CONTRACT.address, - this.signer.provider as ProviderType, + this.provider as ProviderType, chainConfig.SPLITMAIN_CONTRACT.bytecode, ); @@ -507,11 +508,12 @@ export class Client extends Base { chainId: this.chainId, distributorFee: validatedPayload.distributorFee, controllerAddress: validatedPayload.controllerAddress, + provider: this.provider, }); const isSplitterDeployed = await isContractAvailable( predictedSplitterAddress, - this.signer.provider as ProviderType, + this.provider as ProviderType, ); if (!isSplitterDeployed) { @@ -561,7 +563,11 @@ export class Client extends Base { } const signer = this.signer; - return await getOWRTranches({ owrAddress, signer }); + return await getOWRTranches({ + owrAddress, + signer, + provider: this.provider, + }); } /** diff --git a/src/splits/splitHelpers.ts b/src/splits/splitHelpers.ts index 23aaad6..7927a9b 100644 --- a/src/splits/splitHelpers.ts +++ b/src/splits/splitHelpers.ts @@ -7,6 +7,7 @@ import { type SplitV2Recipient, type OVMArgs, type ChainConfig, + type ProviderType, } from '../types.js'; import { Contract, @@ -62,18 +63,53 @@ export const extractOvmAddressFromReceipt = ( ); }; +/** + * ethers deferred filters (`contract.filters.Foo(arg)`) leave subscription + * `fragment` null, so the listener is invoked with ONLY a ContractEventPayload + * — `args[0]` is that object, not the address. Decoded fields live on + * `payload.args`. Never `String(args[0])` (becomes "[object Object]"). + */ +export const resolveOvmAddressFromListenerArgs = ( + args: unknown[], +): { ovm: string; txHash: string } | null => { + const payload = args[args.length - 1] as + | { + log?: { transactionHash?: string }; + args?: { ovm?: unknown; [index: number]: unknown }; + } + | undefined; + + const txHash = payload?.log?.transactionHash; + if (!txHash) return null; + + const candidates: unknown[] = [ + args[0], + payload?.args?.ovm, + payload?.args?.[0], + ]; + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.startsWith('0x')) { + return { ovm: candidate, txHash }; + } + } + return null; +}; + // True when the signer is a smart-contract wallet (e.g. a Safe) going through // a JSON-RPC connection. Local Wallet signers are always EOAs. export const isContractWalletSigner = async ( signer: SignerType, + provider?: ProviderType | null, ): Promise => { if (!signer) { throw new SignerRequiredError('isContractWalletSigner'); } - if (!signer.provider || !('sendUncheckedTransaction' in signer)) { + const readProvider = provider ?? signer.provider; + if (!readProvider || !('sendUncheckedTransaction' in signer)) { return false; } - return await isContractAvailable(await signer.getAddress(), signer.provider); + return await isContractAvailable(await signer.getAddress(), readProvider); }; const SAFE_EXECUTION_SUCCESS_TOPIC = id('ExecutionSuccess(bytes32,uint256)'); @@ -115,6 +151,7 @@ const submitViaContractWalletAndResolveOvm = async ({ factoryAddress, owner, timeoutMs = SAFE_EXECUTION_TIMEOUT_MS, + provider: readProvider, }: { signer: JsonRpcSigner; to: string; @@ -122,8 +159,9 @@ const submitViaContractWalletAndResolveOvm = async ({ factoryAddress: string; owner: string; timeoutMs?: number; + provider?: ProviderType | null; }): Promise => { - const provider = signer.provider; + const provider = readProvider ?? signer.provider; const safeAddress = await signer.getAddress(); const factory = new Contract( factoryAddress, @@ -134,6 +172,10 @@ const submitViaContractWalletAndResolveOvm = async ({ // Captured before submission so the backfill below cannot miss an // execution mined before the wallet call returned (e.g. a 1-of-1 Safe). const startBlock = await provider.getBlockNumber(); + // Pre-populate gasLimit via the caller's provider — sendUncheckedTransaction + // otherwise estimates gas via signer.provider, which for a wallet-connected + // signer may not be able to reach an authenticated RPC. + const gasLimit = await provider.estimateGas({ to, data, from: safeAddress }); const isOurs = async ( candidateTxHash: string, @@ -174,11 +216,8 @@ const submitViaContractWalletAndResolveOvm = async ({ }; const listener = (...args: unknown[]): void => { - const payload = args[args.length - 1] as - | { log?: { transactionHash?: string } } - | undefined; - const txHash = payload?.log?.transactionHash; - if (txHash) consider(String(args[0]), txHash); + const resolved = resolveOvmAddressFromListenerArgs(args); + if (resolved) consider(resolved.ovm, resolved.txHash); }; const timer = setTimeout(() => { @@ -193,14 +232,19 @@ const submitViaContractWalletAndResolveOvm = async ({ void factory.on(filter, listener); signer - .sendUncheckedTransaction({ to, data }) + .sendUncheckedTransaction({ to, data, gasLimit }) .then(async hash => { submittedHash = hash; // Backfill events mined between startBlock and now. const past = await factory.queryFilter(filter, startBlock); for (const ev of past) { - const ovm = (ev as EventLog).args?.[0] as string | undefined; - if (ovm) consider(ovm, ev.transactionHash); + const eventLog = ev as EventLog; + const ovm = (eventLog.args?.ovm ?? eventLog.args?.[0]) as + | string + | undefined; + if (typeof ovm === 'string' && ovm.startsWith('0x')) { + consider(ovm, ev.transactionHash); + } } }) .catch((err: Error) => { @@ -229,17 +273,19 @@ export const submitViaContractWalletAndWait = async ({ data, value, timeoutMs = SAFE_EXECUTION_TIMEOUT_MS, + provider: readProvider, }: { signer: JsonRpcSigner; to: string; data: string; value?: bigint; timeoutMs?: number; + provider?: ProviderType | null; }): Promise => { if (!signer) { throw new SignerRequiredError('submitViaContractWalletAndWait'); } - const provider = signer.provider; + const provider = readProvider ?? signer.provider; const safeAddress = await signer.getAddress(); const filter = { address: safeAddress, @@ -248,6 +294,15 @@ export const submitViaContractWalletAndWait = async ({ // Captured before submission so the backfill below cannot miss an // execution mined before the wallet call returned (e.g. a 1-of-1 Safe). const startBlock = await provider.getBlockNumber(); + // Pre-populate gasLimit via the caller's provider — sendUncheckedTransaction + // otherwise estimates gas via signer.provider, which for a wallet-connected + // signer may not be able to reach an authenticated RPC. + const gasLimit = await provider.estimateGas({ + to, + data, + from: safeAddress, + ...(value ? { value } : {}), + }); const matchesSubmission = ( log: { transactionHash?: string; topics: readonly string[]; data: string }, @@ -308,7 +363,12 @@ export const submitViaContractWalletAndWait = async ({ void provider.on(filter, listener); signer - .sendUncheckedTransaction({ to, data, ...(value ? { value } : {}) }) + .sendUncheckedTransaction({ + to, + data, + gasLimit, + ...(value ? { value } : {}), + }) .then(async hash => { submittedHash = hash; // Backfill logs mined between startBlock and now. @@ -392,6 +452,7 @@ export const predictSplitterAddress = async ({ chainId, distributorFee, controllerAddress, + provider, }: { signer: SignerType; accounts: ETH_ADDRESS[]; @@ -399,12 +460,16 @@ export const predictSplitterAddress = async ({ chainId: number; distributorFee: number; controllerAddress: ETH_ADDRESS; + provider?: ProviderType | null; }): Promise => { try { + // Read-only call: prefer the caller's provider over the signer, whose + // own .provider may be routed through a wallet connector (e.g. + // WalletConnect) rather than an authenticated RPC. const splitMainContractInstance = new Contract( getChainConfig(chainId).SPLITMAIN_CONTRACT.address, splitMainEthereumAbi, - signer, + provider ?? signer, ); let predictedSplitterAddress: string; @@ -767,12 +832,21 @@ export const deploySplitterAndOWRContracts = async ({ export const getOWRTranches = async ({ owrAddress, signer, + provider, }: { owrAddress: ETH_ADDRESS; signer: SignerType; + provider?: ProviderType | null; }): Promise => { try { - const owrContract = new Contract(owrAddress, OWRContract.abi, signer); + // Read-only call: prefer the caller's provider over the signer, whose + // own .provider may be routed through a wallet connector (e.g. + // WalletConnect) rather than an authenticated RPC. + const owrContract = new Contract( + owrAddress, + OWRContract.abi, + provider ?? signer, + ); let res; try { @@ -942,6 +1016,7 @@ export const predictSplitV2Address = async ({ salt, signer, chainId, + provider, }: { splitOwnerAddress: string; recipients: SplitV2Recipient[]; @@ -949,6 +1024,7 @@ export const predictSplitV2Address = async ({ salt: `0x${string}`; signer: SignerType; chainId: number; + provider?: ProviderType | null; }): Promise => { try { const chainConfig = getChainConfig(chainId); @@ -956,10 +1032,13 @@ export const predictSplitV2Address = async ({ throw new Error(`SplitV2 Factory not configured for chain ${chainId}`); } + // Read-only call: prefer the caller's provider over the signer, whose + // own .provider may be routed through a wallet connector (e.g. + // WalletConnect) rather than an authenticated RPC. const splitV2FactoryContract = new Contract( chainConfig.SPLIT_V2_FACTORY_CONTRACT.address, splitV2FactoryAbi, - signer, + provider ?? signer, ); const splitParams = createSplitV2Params(recipients, distributorFeePercent); @@ -983,6 +1062,7 @@ export const isSplitV2Deployed = async ({ salt, signer, chainId, + provider, }: { splitOwnerAddress: string; recipients: SplitV2Recipient[]; @@ -990,6 +1070,7 @@ export const isSplitV2Deployed = async ({ salt: `0x${string}`; signer: SignerType; chainId: number; + provider?: ProviderType | null; }): Promise => { try { const chainConfig = getChainConfig(chainId); @@ -997,10 +1078,13 @@ export const isSplitV2Deployed = async ({ throw new Error(`SplitV2 Factory not configured for chain ${chainId}`); } + // Read-only call: prefer the caller's provider over the signer, whose + // own .provider may be routed through a wallet connector (e.g. + // WalletConnect) rather than an authenticated RPC. const splitV2FactoryContract = new Contract( chainConfig.SPLIT_V2_FACTORY_CONTRACT.address, splitV2FactoryAbi, - signer, + provider ?? signer, ); const splitParams = createSplitV2Params(recipients, distributorFeePercent); @@ -1025,6 +1109,7 @@ export const deployOVMContract = async ({ principalThreshold, signer, chainId, + provider, }: { OVMOwnerAddress: string; principalRecipient: string; @@ -1032,6 +1117,7 @@ export const deployOVMContract = async ({ principalThreshold: number; signer: SignerType; chainId: number; + provider?: ProviderType | null; }): Promise => { try { const chainConfig = getChainConfig(chainId); @@ -1054,7 +1140,7 @@ export const deployOVMContract = async ({ if ( 'sendUncheckedTransaction' in signer && - (await isContractWalletSigner(signer)) + (await isContractWalletSigner(signer, provider)) ) { return await submitViaContractWalletAndResolveOvm({ signer, @@ -1065,6 +1151,7 @@ export const deployOVMContract = async ({ ), factoryAddress: chainConfig.OVM_FACTORY_CONTRACT.address, owner: OVMOwnerAddress, + provider, }); } @@ -1091,6 +1178,7 @@ export const deployOVMAndSplitV2 = async ({ principalSplitRecipients, isPrincipalSplitDeployed, splitOwnerAddress, + provider, }: { ovmArgs: OVMArgs; rewardRecipients: SplitV2Recipient[]; @@ -1102,6 +1190,7 @@ export const deployOVMAndSplitV2 = async ({ principalSplitRecipients?: SplitV2Recipient[]; isPrincipalSplitDeployed?: boolean; splitOwnerAddress: string; + provider?: ProviderType | null; }): Promise => { try { const chainConfig = getChainConfig(chainId); @@ -1165,7 +1254,7 @@ export const deployOVMAndSplitV2 = async ({ if ( 'sendUncheckedTransaction' in signer && - (await isContractWalletSigner(signer)) + (await isContractWalletSigner(signer, provider)) ) { return await submitViaContractWalletAndResolveOvm({ signer, @@ -1175,6 +1264,7 @@ export const deployOVMAndSplitV2 = async ({ ]), factoryAddress: chainConfig.OVM_FACTORY_CONTRACT.address, owner: ovmArgs.OVMOwnerAddress, + provider, }); } diff --git a/src/splits/splits.ts b/src/splits/splits.ts index 7721a7b..93eac39 100644 --- a/src/splits/splits.ts +++ b/src/splits/splits.ts @@ -204,6 +204,7 @@ export class ObolSplits { salt, signer: this.signer, chainId: this.chainId, + provider: this.provider, }); const isRewardSplitterDeployed = await isSplitV2Deployed({ @@ -213,6 +214,7 @@ export class ObolSplits { salt, signer: this.signer, chainId: this.chainId, + provider: this.provider, }); if (isRewardSplitterDeployed) { @@ -223,6 +225,7 @@ export class ObolSplits { principalThreshold: validatedPayload.principalThreshold, signer: this.signer, chainId: this.chainId, + provider: this.provider, }); return { @@ -244,6 +247,7 @@ export class ObolSplits { signer: this.signer, chainId: this.chainId, splitOwnerAddress: validatedPayload.splitOwnerAddress, + provider: this.provider, }); return { @@ -388,6 +392,7 @@ export class ObolSplits { salt, signer: this.signer, chainId: this.chainId, + provider: this.provider, }); const predictedPrincipalSplitAddress = await predictSplitV2Address({ @@ -397,6 +402,7 @@ export class ObolSplits { salt, signer: this.signer, chainId: this.chainId, + provider: this.provider, }); const isRewardsSplitterDeployed = await isSplitV2Deployed({ @@ -406,6 +412,7 @@ export class ObolSplits { salt, signer: this.signer, chainId: this.chainId, + provider: this.provider, }); const isPrincipalSplitterDeployed = await isSplitV2Deployed({ @@ -415,6 +422,7 @@ export class ObolSplits { salt, signer: this.signer, chainId: this.chainId, + provider: this.provider, }); if (isRewardsSplitterDeployed && isPrincipalSplitterDeployed) { @@ -425,6 +433,7 @@ export class ObolSplits { principalThreshold: validatedPayload.principalThreshold, signer: this.signer, chainId: this.chainId, + provider: this.provider, }); return { @@ -448,6 +457,7 @@ export class ObolSplits { principalSplitRecipients, isPrincipalSplitDeployed: isPrincipalSplitterDeployed, splitOwnerAddress: validatedPayload.splitOwnerAddress, + provider: this.provider, }); return { diff --git a/test/eoa/eoa.spec.ts b/test/eoa/eoa.spec.ts index 539c52b..0fcba33 100644 --- a/test/eoa/eoa.spec.ts +++ b/test/eoa/eoa.spec.ts @@ -15,9 +15,8 @@ await jest.unstable_mockModule('../../src/eoa/eoaHelpers.js', () => ({ // @ts-expect-error - ESM dynamic import returns promise const { EOA } = await import('../../src/eoa/eoa.js'); // @ts-expect-error - ESM dynamic import returns promise -const { submitEOAWithdrawalRequest, submitEOABatchDeposit } = await import( - '../../src/eoa/eoaHelpers.js' -); +const { submitEOAWithdrawalRequest, submitEOABatchDeposit } = + await import('../../src/eoa/eoaHelpers.js'); // helpers are mocked via unstable_mockModule above @@ -80,6 +79,7 @@ describe('EOA', () => { requiredFee: mockPayload.requiredFee, chainId: 1, signer: mockSigner, + provider: mockProvider, }); expect(result).toEqual(mockResult); @@ -124,9 +124,7 @@ describe('EOA', () => { await expect( eoaUnsupportedChain.requestWithdrawal(mockPayload), - ).rejects.toThrow( - 'EOA requestWithdrawal is not supported on chain 999', - ); + ).rejects.toThrow('EOA requestWithdrawal is not supported on chain 999'); }); it('should return null txHash when transaction receipt is null', async () => { @@ -201,6 +199,7 @@ describe('EOA', () => { batchDepositContractAddress: '0xcD7a6C118Ac8F6544BC5076F2D8Fb86D2C546756', signer: mockSigner, + provider: mockProvider, }); expect(result).toEqual(mockResult); @@ -284,6 +283,7 @@ describe('EOA', () => { batchDepositContractAddress: '0xcD7a6C118Ac8F6544BC5076F2D8Fb86D2C546756', signer: mockSigner, + provider: mockProvider, }); expect(result).toEqual(mockResult); diff --git a/test/eoa/safeEoaHelpers.spec.ts b/test/eoa/safeEoaHelpers.spec.ts index 7343081..e243194 100644 --- a/test/eoa/safeEoaHelpers.spec.ts +++ b/test/eoa/safeEoaHelpers.spec.ts @@ -43,6 +43,7 @@ const makeSafeSigner = ({ const provider = { getBlockNumber: jest.fn(async () => 100), getCode: jest.fn(async () => '0xabcdef'), // contract wallet + estimateGas: jest.fn(async () => 21000n), getLogs: jest.fn(async () => pastLogs), on: jest.fn(async (_filter, listener) => { capturedListener = listener; @@ -79,6 +80,7 @@ describe('submitViaContractWalletAndWait', () => { expect(signer.sendUncheckedTransaction).toHaveBeenCalledWith({ to: TARGET, data: '0x1234', + gasLimit: 21000n, value: 1n, }); }); @@ -156,7 +158,9 @@ describe('submitEOAWithdrawalRequest signer branching', () => { const wait = jest.fn(async () => ({ hash: EXECUTED_TX_HASH })); const signer = { provider: { getCode: jest.fn(async () => '0x') }, - getAddress: jest.fn(async () => '0x1111111111111111111111111111111111111111'), + getAddress: jest.fn( + async () => '0x1111111111111111111111111111111111111111', + ), // No sendUncheckedTransaction => plain EOA signer. sendTransaction: jest.fn(async () => ({ wait })), }; diff --git a/test/splits/ovmDeployHelpers.spec.ts b/test/splits/ovmDeployHelpers.spec.ts index 0becb36..539d45d 100644 --- a/test/splits/ovmDeployHelpers.spec.ts +++ b/test/splits/ovmDeployHelpers.spec.ts @@ -4,6 +4,7 @@ import { OVMFactoryContract } from '../../src/abi/OVM'; import { extractOvmAddressFromReceipt, receiptMatchesSafeTx, + resolveOvmAddressFromListenerArgs, } from '../../src/splits/splitHelpers'; const OVM_ADDRESS = '0x1234567890123456789012345678901234567890'; @@ -43,6 +44,42 @@ describe('extractOvmAddressFromReceipt', () => { }); }); +describe('resolveOvmAddressFromListenerArgs', () => { + const TX_HASH = '0x' + 'bb'.repeat(32); + + it('reads ovm from payload.args when deferred filter only passes payload', () => { + // ethers deferred filters invoke the listener with ONLY ContractEventPayload + const payload = { + log: { transactionHash: TX_HASH }, + args: { ovm: OVM_ADDRESS, 0: OVM_ADDRESS }, + }; + expect(resolveOvmAddressFromListenerArgs([payload])).toEqual({ + ovm: OVM_ADDRESS, + txHash: TX_HASH, + }); + }); + + it('does not stringify the payload into "[object Object]"', () => { + const payload = { + log: { transactionHash: TX_HASH }, + args: { ovm: OVM_ADDRESS }, + }; + const resolved = resolveOvmAddressFromListenerArgs([payload]); + expect(resolved?.ovm).not.toBe('[object Object]'); + expect(resolved?.ovm).toBe(OVM_ADDRESS); + }); + + it('prefers a leading address string when present', () => { + const payload = { + log: { transactionHash: TX_HASH }, + args: { ovm: OVM_ADDRESS }, + }; + expect( + resolveOvmAddressFromListenerArgs([OVM_ADDRESS, OWNER, payload]), + ).toEqual({ ovm: OVM_ADDRESS, txHash: TX_HASH }); + }); +}); + describe('receiptMatchesSafeTx', () => { it('matches Safe v1.4.1 ExecutionSuccess (indexed safeTxHash)', () => { const receipt = { diff --git a/test/splits/splits.spec.ts b/test/splits/splits.spec.ts index d746e69..55c290f 100644 --- a/test/splits/splits.spec.ts +++ b/test/splits/splits.spec.ts @@ -213,7 +213,9 @@ describe('ObolSplits', () => { clientUnsupportedChain.splits.createValidatorManagerAndRewardsSplit( mockRewardsSplitPayload, ), - ).rejects.toThrow('createValidatorManagerAndRewardsSplit is not supported on chain 999'); + ).rejects.toThrow( + 'createValidatorManagerAndRewardsSplit is not supported on chain 999', + ); }); }); @@ -324,6 +326,7 @@ describe('ObolSplits', () => { principalThreshold: mockTotalSplitPayload.principalThreshold, signer: mockSigner, chainId: 1, + provider: mockProvider, }); }); });