From 648f55d02d339e9d47bfc192cff056676bfe1bdc Mon Sep 17 00:00:00 2001 From: Matt Rintoul Date: Thu, 28 May 2026 14:03:32 -0400 Subject: [PATCH 1/3] feat(relayer): propagate sponsored signal and mark swallowed errors `RpcRelayer.feeOptions` now forwards the server's `sponsored: boolean` to callers, and both `feeOptions` and `feeTokens` mark their swallowed-error returns with `failed: true`. The `Relayer` interface and all bundled implementations (Rpc, Sequence, Local, EIP6963, Pk) are widened to match. Additive change: existing consumers ignoring the new fields are unaffected. Downstream sponsorship classifiers should switch from `!feeOption` inference to `sponsored === true` so a real subsidy is no longer indistinguishable from a swallowed `/FeeOptions` error. Co-Authored-By: Claude Opus 4.7 --- .changeset/relayer-sponsored-signal.md | 20 +++++++ .../services/relayer/src/relayer/relayer.ts | 9 ++- .../relayer/src/relayer/rpc-relayer/index.ts | 15 +++-- .../relayer/src/relayer/standard/eip6963.ts | 9 ++- .../relayer/src/relayer/standard/local.ts | 11 +++- .../src/relayer/standard/pk-relayer.ts | 9 ++- .../relayer/src/relayer/standard/sequence.ts | 12 +++- .../relayer/test/relayer/relayer.test.ts | 55 +++++++++++++++++++ 8 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 .changeset/relayer-sponsored-signal.md diff --git a/.changeset/relayer-sponsored-signal.md b/.changeset/relayer-sponsored-signal.md new file mode 100644 index 000000000..f922932ed --- /dev/null +++ b/.changeset/relayer-sponsored-signal.md @@ -0,0 +1,20 @@ +--- +'@0xsequence/relayer': minor +--- + +Surface explicit sponsorship signal on `feeOptions` and an error marker on +`feeOptions` / `feeTokens`. + +- `RpcRelayer.feeOptions` now returns `sponsored: boolean`, forwarded from the + server's `FeeOptionsReturn.sponsored`. The `Relayer` interface and all + bundled implementations (`RpcRelayer`, `SequenceRelayer`, `LocalRelayer`, + `EIP6963Relayer`, `PkRelayer`) carry the new field. +- When `feeOptions` swallows a transport / server error it now returns + `{ options: [], sponsored: false, failed: true }` (was `{ options: [] }`). +- When `feeTokens` swallows an error it now returns + `{ isFeeRequired: false, failed: true }` (was `{ isFeeRequired: false }`). + +These changes are additive — existing consumers that ignore the new fields are +unaffected. Consumers that classified sponsorship by "no fee option attached" +should migrate to `sponsored === true` to distinguish a real subsidy from a +swallowed `/FeeOptions` error. diff --git a/packages/services/relayer/src/relayer/relayer.ts b/packages/services/relayer/src/relayer/relayer.ts index 9f648004a..d0c558082 100644 --- a/packages/services/relayer/src/relayer/relayer.ts +++ b/packages/services/relayer/src/relayer/relayer.ts @@ -11,7 +11,12 @@ export interface Relayer { isAvailable(wallet: Address.Address, chainId: number): Promise - feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: FeeToken[]; paymentAddress?: Address.Address }> + feeTokens(): Promise<{ + isFeeRequired: boolean + tokens?: FeeToken[] + paymentAddress?: Address.Address + failed?: boolean + }> feeOptions( wallet: Address.Address, @@ -19,7 +24,7 @@ export interface Relayer { to: Address.Address, calls: Payload.Call[], data?: Hex.Hex, - ): Promise<{ options: FeeOption[]; quote?: FeeQuote }> + ): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }> relay(to: Address.Address, data: Hex.Hex, chainId: number, quote?: FeeQuote): Promise<{ opHash: Hex.Hex }> diff --git a/packages/services/relayer/src/relayer/rpc-relayer/index.ts b/packages/services/relayer/src/relayer/rpc-relayer/index.ts index 814d25bbd..05ba8be76 100644 --- a/packages/services/relayer/src/relayer/rpc-relayer/index.ts +++ b/packages/services/relayer/src/relayer/rpc-relayer/index.ts @@ -123,7 +123,12 @@ export class RpcRelayer implements Relayer { return Promise.resolve(this.chainId === chainId) } - async feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: RpcFeeToken[]; paymentAddress?: Address.Address }> { + async feeTokens(): Promise<{ + isFeeRequired: boolean + tokens?: RpcFeeToken[] + paymentAddress?: Address.Address + failed?: boolean + }> { try { const { isFeeRequired, tokens, paymentAddress } = await this.client.feeTokens() if (isFeeRequired) { @@ -140,7 +145,7 @@ export class RpcRelayer implements Relayer { } } catch (e) { console.warn('RpcRelayer.feeTokens failed:', e) - return { isFeeRequired: false } + return { isFeeRequired: false, failed: true } } } @@ -150,7 +155,7 @@ export class RpcRelayer implements Relayer { to: Address.Address, calls: Payload.Call[], data?: Hex.Hex, - ): Promise<{ options: FeeOption[]; quote?: FeeQuote }> { + ): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }> { // IMPORTANT: // The relayer FeeOptions endpoint simulates `eth_call(to, data)`. // Callers that already built a wallet transaction should pass its `to` and `data`. @@ -182,10 +187,10 @@ export class RpcRelayer implements Relayer { gasLimit: option.gasLimit, })) - return { options, quote } + return { options, quote, sponsored: result.sponsored } } catch (e) { console.warn('RpcRelayer.feeOptions failed:', e) - return { options: [] } + return { options: [], sponsored: false, failed: true } } } diff --git a/packages/services/relayer/src/relayer/standard/eip6963.ts b/packages/services/relayer/src/relayer/standard/eip6963.ts index a290b2c6f..957b13c44 100644 --- a/packages/services/relayer/src/relayer/standard/eip6963.ts +++ b/packages/services/relayer/src/relayer/standard/eip6963.ts @@ -23,7 +23,12 @@ export class EIP6963Relayer implements Relayer { return this.relayer.isAvailable(wallet, chainId) } - feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: FeeToken[]; paymentAddress?: Address.Address }> { + feeTokens(): Promise<{ + isFeeRequired: boolean + tokens?: FeeToken[] + paymentAddress?: Address.Address + failed?: boolean + }> { return this.relayer.feeTokens() } @@ -32,7 +37,7 @@ export class EIP6963Relayer implements Relayer { chainId: number, to: Address.Address, calls: Payload.Call[], - ): Promise<{ options: FeeOption[]; quote?: FeeQuote }> { + ): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }> { return this.relayer.feeOptions(wallet, chainId, to, calls) } diff --git a/packages/services/relayer/src/relayer/standard/local.ts b/packages/services/relayer/src/relayer/standard/local.ts index 4135af3b3..e8066674c 100644 --- a/packages/services/relayer/src/relayer/standard/local.ts +++ b/packages/services/relayer/src/relayer/standard/local.ts @@ -56,7 +56,12 @@ export class LocalRelayer implements Relayer { return new LocalRelayer(new EIP1193ProviderAdapter(provider)) } - feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: FeeToken[]; paymentAddress?: Address.Address }> { + feeTokens(): Promise<{ + isFeeRequired: boolean + tokens?: FeeToken[] + paymentAddress?: Address.Address + failed?: boolean + }> { return Promise.resolve({ isFeeRequired: false, }) @@ -67,8 +72,8 @@ export class LocalRelayer implements Relayer { _chainId: number, _to: Address.Address, _calls: Payload.Call[], - ): Promise<{ options: FeeOption[]; quote?: FeeQuote }> { - return Promise.resolve({ options: [] }) + ): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }> { + return Promise.resolve({ options: [], sponsored: false }) } async relay( diff --git a/packages/services/relayer/src/relayer/standard/pk-relayer.ts b/packages/services/relayer/src/relayer/standard/pk-relayer.ts index b1d420a58..28ea0850a 100644 --- a/packages/services/relayer/src/relayer/standard/pk-relayer.ts +++ b/packages/services/relayer/src/relayer/standard/pk-relayer.ts @@ -107,7 +107,12 @@ export class PkRelayer implements Relayer { return providerChainId === chainId } - feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: FeeToken[]; paymentAddress?: Address.Address }> { + feeTokens(): Promise<{ + isFeeRequired: boolean + tokens?: FeeToken[] + paymentAddress?: Address.Address + failed?: boolean + }> { return this.relayer.feeTokens() } @@ -116,7 +121,7 @@ export class PkRelayer implements Relayer { chainId: number, to: Address.Address, calls: Payload.Call[], - ): Promise<{ options: FeeOption[]; quote?: FeeQuote }> { + ): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }> { return this.relayer.feeOptions(wallet, chainId, to, calls) } diff --git a/packages/services/relayer/src/relayer/standard/sequence.ts b/packages/services/relayer/src/relayer/standard/sequence.ts index 1ae5ec69b..8519c618a 100644 --- a/packages/services/relayer/src/relayer/standard/sequence.ts +++ b/packages/services/relayer/src/relayer/standard/sequence.ts @@ -17,7 +17,12 @@ export class SequenceRelayer implements Relayer { return true } - async feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: FeeToken[]; paymentAddress?: Address.Address }> { + async feeTokens(): Promise<{ + isFeeRequired: boolean + tokens?: FeeToken[] + paymentAddress?: Address.Address + failed?: boolean + }> { const { isFeeRequired, tokens, paymentAddress } = await this.service.feeTokens() if (isFeeRequired) { Address.assert(paymentAddress) @@ -39,17 +44,18 @@ export class SequenceRelayer implements Relayer { to: Address.Address, calls: Payload.Call[], transactionData?: Hex.Hex, - ): Promise<{ options: FeeOption[]; quote?: FeeQuote }> { + ): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }> { const execute = AbiFunction.from('function execute(bytes calldata _payload, bytes calldata _signature)') const payload = Payload.encode({ type: 'call', space: 0n, nonce: 0n, calls }, to) const signature = '0x0001' // TODO: use a stub signature const data = transactionData ?? AbiFunction.encodeData(execute, [Bytes.toHex(payload), signature]) - const { options, quote } = await this.service.feeOptions({ wallet, to, data }) + const { options, quote, sponsored } = await this.service.feeOptions({ wallet, to, data }) return { options, quote: quote ? { _tag: 'FeeQuote', _quote: quote } : undefined, + sponsored, } } diff --git a/packages/services/relayer/test/relayer/relayer.test.ts b/packages/services/relayer/test/relayer/relayer.test.ts index 028ccf32f..cd8c096a3 100644 --- a/packages/services/relayer/test/relayer/relayer.test.ts +++ b/packages/services/relayer/test/relayer/relayer.test.ts @@ -294,6 +294,7 @@ describe('Relayer', () => { vi.mocked(mockRelayer.feeOptions).mockResolvedValue({ options: [], quote: undefined, + sponsored: false, }) vi.mocked(mockRelayer.relay).mockResolvedValue({ opHash: TEST_OP_HASH, @@ -375,6 +376,60 @@ describe('Relayer', () => { data: expectedData, }) }) + + it('should propagate sponsored:true from the server', async () => { + const fetchImpl = vi.fn( + async () => new Response(JSON.stringify({ options: [], sponsored: true }), { status: 200 }), + ) + const relayer = new Relayer.RpcRelayer('https://relayer.test', TEST_CHAIN_ID, 'https://rpc.test', fetchImpl) + + const result = await relayer.feeOptions(TEST_WALLET_ADDRESS, TEST_CHAIN_ID, TEST_TO_ADDRESS, [mockCall]) + + expect(result.sponsored).toBe(true) + expect(result.options).toEqual([]) + expect(result.failed).toBeUndefined() + }) + + it('should propagate sponsored:false from the server', async () => { + const fetchImpl = vi.fn( + async () => new Response(JSON.stringify({ options: [], sponsored: false }), { status: 200 }), + ) + const relayer = new Relayer.RpcRelayer('https://relayer.test', TEST_CHAIN_ID, 'https://rpc.test', fetchImpl) + + const result = await relayer.feeOptions(TEST_WALLET_ADDRESS, TEST_CHAIN_ID, TEST_TO_ADDRESS, [mockCall]) + + expect(result.sponsored).toBe(false) + expect(result.failed).toBeUndefined() + }) + + it('should return sponsored:false and failed:true when the server errors', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ error: 'Aborted', code: 1005, msg: 'simulation failed' }), { status: 400 }), + ) + const relayer = new Relayer.RpcRelayer('https://relayer.test', TEST_CHAIN_ID, 'https://rpc.test', fetchImpl) + + const result = await relayer.feeOptions(TEST_WALLET_ADDRESS, TEST_CHAIN_ID, TEST_TO_ADDRESS, [mockCall]) + + expect(result).toEqual({ options: [], sponsored: false, failed: true }) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + }) + + describe('RpcRelayer.feeTokens', () => { + it('should return failed:true when the server errors', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const fetchImpl = vi.fn(async () => new Response('boom', { status: 500 })) + const relayer = new Relayer.RpcRelayer('https://relayer.test', TEST_CHAIN_ID, 'https://rpc.test', fetchImpl) + + const result = await relayer.feeTokens() + + expect(result).toEqual({ isFeeRequired: false, failed: true }) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) }) describe('Type compatibility', () => { From 583aef0a57f1c8210eeaed0d4e75c01e7396784e Mon Sep 17 00:00:00 2001 From: Matt Rintoul Date: Thu, 28 May 2026 14:13:26 -0400 Subject: [PATCH 2/3] feat(wallet-wdk): carry sponsored/failed on StandardRelayerOption `StandardRelayerOption` gains optional `sponsored` and `failed` fields, populated on both construction branches in `transactions.ts` from the relayer SDK's new `feeOptions` return. `isStandardRelayerOption` / `isERC4337RelayerOption` are re-exported so consumers can narrow before reading the new fields. UI consumers that classified sponsorship by "no fee option attached" should switch to `sponsored === true` to distinguish a real subsidy from a swallowed `/FeeOptions` error. Co-Authored-By: Claude Opus 4.7 --- .changeset/wdk-sponsored-signal.md | 23 +++++++++++++++++++ .../wallet/wdk/src/sequence/transactions.ts | 4 ++++ .../wallet/wdk/src/sequence/types/index.ts | 1 + .../src/sequence/types/transaction-request.ts | 2 ++ packages/wallet/wdk/test/transactions.test.ts | 17 ++++++++++++++ 5 files changed, 47 insertions(+) create mode 100644 .changeset/wdk-sponsored-signal.md diff --git a/.changeset/wdk-sponsored-signal.md b/.changeset/wdk-sponsored-signal.md new file mode 100644 index 000000000..14d94ddce --- /dev/null +++ b/.changeset/wdk-sponsored-signal.md @@ -0,0 +1,23 @@ +--- +'@0xsequence/wallet-wdk': minor +--- + +Carry `sponsored` / `failed` through `StandardRelayerOption`. + +`StandardRelayerOption` now exposes two optional fields: + +- `sponsored?: boolean` — populated from the relayer SDK's new `feeOptions` + return field. `true` means the server confirmed an active sponsorship policy + match; `false` means it did not (or the quote failed). +- `failed?: boolean` — `true` when the relayer's `feeOptions` call was swallowed + due to a transport or server error. + +Both fields are populated on the empty-options construction branch and the +per-option mapping branch in `transactions.ts`. The new `isStandardRelayerOption` +and `isERC4337RelayerOption` runtime helpers are now re-exported from the +package root for consumers that need to narrow `RelayerOption` before reading +the new fields. + +UI / wallet consumers that previously classified sponsorship by "no `feeOption` +attached" should switch to `sponsored === true` so a real subsidy is no longer +indistinguishable from a swallowed `/FeeOptions` error. diff --git a/packages/wallet/wdk/src/sequence/transactions.ts b/packages/wallet/wdk/src/sequence/transactions.ts index 146d66999..c5549f28d 100644 --- a/packages/wallet/wdk/src/sequence/transactions.ts +++ b/packages/wallet/wdk/src/sequence/transactions.ts @@ -371,6 +371,8 @@ export class Transactions implements TransactionsInterface { id: uuidv7(), relayerType: relayer.type, relayerId: relayer.id, + sponsored: feeOptions.sponsored, + failed: feeOptions.failed, name, icon, } as StandardRelayerOption, @@ -383,6 +385,8 @@ export class Transactions implements TransactionsInterface { feeOption, relayerType: relayer.type, relayerId: relayer.id, + sponsored: feeOptions.sponsored, + failed: feeOptions.failed, quote: feeOptions.quote, })) }), diff --git a/packages/wallet/wdk/src/sequence/types/index.ts b/packages/wallet/wdk/src/sequence/types/index.ts index 066e8a071..946ee9bf3 100644 --- a/packages/wallet/wdk/src/sequence/types/index.ts +++ b/packages/wallet/wdk/src/sequence/types/index.ts @@ -27,5 +27,6 @@ export type { TransactionRequest, TransactionRequested, } from './transaction-request.js' +export { isERC4337RelayerOption, isStandardRelayerOption } from './transaction-request.js' export type { Wallet } from './wallet.js' export type { Module } from './module.js' diff --git a/packages/wallet/wdk/src/sequence/types/transaction-request.ts b/packages/wallet/wdk/src/sequence/types/transaction-request.ts index 51160a049..5511bc61b 100644 --- a/packages/wallet/wdk/src/sequence/types/transaction-request.ts +++ b/packages/wallet/wdk/src/sequence/types/transaction-request.ts @@ -21,6 +21,8 @@ export type StandardRelayerOption = BaseRelayerOption & { kind: 'standard' feeOption?: Relayer.FeeOption quote?: Relayer.FeeQuote + sponsored?: boolean + failed?: boolean name?: string icon?: string } diff --git a/packages/wallet/wdk/test/transactions.test.ts b/packages/wallet/wdk/test/transactions.test.ts index 295d00d01..8d494347d 100644 --- a/packages/wallet/wdk/test/transactions.test.ts +++ b/packages/wallet/wdk/test/transactions.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { + isStandardRelayerOption, Manager, SignerActionable, Transaction, @@ -56,6 +57,14 @@ describe('Transactions', () => { expect(tx.relayerOptions.length).toBe(1) expect(tx.relayerOptions[0]!.id).toBeDefined() + // PkRelayer/LocalRelayer never report sponsorship, so sponsored should be + // explicitly false (not undefined) and failed should remain undefined. + const firstOption = tx.relayerOptions[0]! + if (isStandardRelayerOption(firstOption)) { + expect(firstOption.sponsored).toBe(false) + expect(firstOption.failed).toBeUndefined() + } + const sigId = await manager.transactions.selectRelayer(txId!, tx.relayerOptions[0]!.id) expect(sigId).toBeDefined() @@ -169,6 +178,14 @@ describe('Transactions', () => { expect(tx.relayerOptions.length).toBe(1) expect(tx.relayerOptions[0]!.id).toBeDefined() + // PkRelayer/LocalRelayer never report sponsorship, so sponsored should be + // explicitly false (not undefined) and failed should remain undefined. + const firstOption = tx.relayerOptions[0]! + if (isStandardRelayerOption(firstOption)) { + expect(firstOption.sponsored).toBe(false) + expect(firstOption.failed).toBeUndefined() + } + const sigId = await manager.transactions.selectRelayer(txId!, tx.relayerOptions[0]!.id) expect(sigId).toBeDefined() From 7e34cf7d090361a799967d034dc97a0745ad3556 Mon Sep 17 00:00:00 2001 From: Matt Rintoul Date: Thu, 28 May 2026 14:19:41 -0400 Subject: [PATCH 3/3] feat(dapp-client): add isSponsored for explicit sponsorship checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DappClient.isSponsored(chainId, transactions)` and `ChainSessionManager.isSponsored(calls)` return true only when the relayer's `/FeeOptions` endpoint explicitly reports sponsorship; any error, network failure, or absence of sponsorship returns false. A true result is always safe to surface as "free gas" in UI. Prefer this over inferring sponsorship from an empty `getFeeOptions` array — a swallowed `/FeeOptions` error produces the same empty shape as a real subsidy. `getFeeOptions` is unchanged. Co-Authored-By: Claude Opus 4.7 --- .changeset/dapp-client-is-sponsored.md | 19 ++++++++ .../dapp-client/src/ChainSessionManager.ts | 48 +++++++++++++++++++ packages/wallet/dapp-client/src/DappClient.ts | 24 ++++++++++ 3 files changed, 91 insertions(+) create mode 100644 .changeset/dapp-client-is-sponsored.md diff --git a/.changeset/dapp-client-is-sponsored.md b/.changeset/dapp-client-is-sponsored.md new file mode 100644 index 000000000..731c28d3b --- /dev/null +++ b/.changeset/dapp-client-is-sponsored.md @@ -0,0 +1,19 @@ +--- +'@0xsequence/dapp-client': minor +--- + +Add `isSponsored` for explicit sponsorship checks. + +`DappClient.isSponsored(chainId, transactions)` and +`ChainSessionManager.isSponsored(calls)` return `true` only when the relayer's +`/FeeOptions` endpoint explicitly reports sponsorship. Any error, network +failure, or absence of sponsorship returns `false`, so a `true` result is +always safe to surface as "free gas" in UI. + +Prefer this over inferring sponsorship from an empty `getFeeOptions` array — a +swallowed `/FeeOptions` error also produces an empty array, so the inference +can misclassify a failed quote as a real subsidy. The new method uses the +positive `sponsored: boolean` signal from `@0xsequence/relayer`'s widened +`feeOptions` return. + +`getFeeOptions` is unchanged. diff --git a/packages/wallet/dapp-client/src/ChainSessionManager.ts b/packages/wallet/dapp-client/src/ChainSessionManager.ts index dc1f30e23..d2ebd1a8e 100644 --- a/packages/wallet/dapp-client/src/ChainSessionManager.ts +++ b/packages/wallet/dapp-client/src/ChainSessionManager.ts @@ -878,6 +878,54 @@ export class ChainSessionManager { } } + /** + * Checks whether the given transactions would be sponsored by an active + * relayer policy on this chain. + * + * Returns `true` only when the relayer's `/FeeOptions` endpoint explicitly + * reports sponsorship. A failed quote, network error, or absence of + * sponsorship all return `false`, so a `true` result is always safe to + * surface as "free gas" in UI. + */ + async isSponsored(calls: Transaction[]): Promise { + const callsToSend = calls.map((tx) => ({ + to: tx.to, + value: tx.value, + data: tx.data, + gasLimit: tx.gasLimit ?? BigInt(0), + delegateCall: tx.delegateCall ?? false, + onlyFallback: tx.onlyFallback ?? false, + behaviorOnError: tx.behaviorOnError ?? ('revert' as const), + })) + try { + const signedCall = await this._buildAndSignCalls(callsToSend) + const fingerprint = this._fingerprintCalls(callsToSend) + if (fingerprint) { + this.lastSignedCallCache = { + fingerprint, + signedCall, + createdAtMs: Date.now(), + } + } + const walletAddress = this.walletAddress + if (!walletAddress) throw new InitializationError('Wallet is not initialized.') + const feeOptions = await this.relayer.feeOptions( + walletAddress, + this.chainId, + signedCall.to, + callsToSend, + signedCall.data, + ) + return feeOptions.sponsored === true && !feeOptions.failed + } catch (err) { + console.warn( + `isSponsored check failed for chain ${this.chainId}:`, + err instanceof Error ? err.message : String(err), + ) + return false + } + } + /** * Builds, signs, and sends a batch of transactions. * @param transactions The transactions to be sent. diff --git a/packages/wallet/dapp-client/src/DappClient.ts b/packages/wallet/dapp-client/src/DappClient.ts index e580bd912..cc6bcba4d 100644 --- a/packages/wallet/dapp-client/src/DappClient.ts +++ b/packages/wallet/dapp-client/src/DappClient.ts @@ -806,6 +806,30 @@ export class DappClient { return await chainSessionManager.getFeeOptions(transactions) } + /** + * Checks whether the given transactions would be sponsored on `chainId`. + * + * Returns `true` only when the relayer's `/FeeOptions` endpoint explicitly + * reports sponsorship. A failed quote, network error, or absence of + * sponsorship all return `false`, so a `true` result is always safe to + * surface as "free gas" in UI. + * + * Prefer this over inferring sponsorship from an empty `getFeeOptions` + * array — a swallowed `/FeeOptions` error also produces an empty array. + * + * @example + * if (await dappClient.isSponsored(1, transactions)) { + * // safe to show "Free gas, sponsored by app" + * } else { + * const feeOptions = await dappClient.getFeeOptions(1, transactions) + * // present feeOptions[0..n] to the user as payment choices + * } + */ + async isSponsored(chainId: number, transactions: Transaction[]): Promise { + const chainSessionManager = await this.getOrInitializeChainManager(chainId) + return await chainSessionManager.isSponsored(transactions) + } + /** * Fetches fee tokens for a chain. * @returns A promise that resolves with the fee tokens response. {@link GetFeeTokensResponse}