Skip to content

Commit 55121af

Browse files
matt416claude
andauthored
Surface relayer sponsorship signal end-to-end (additive, non-breaking) (#1007)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat(dapp-client): 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. 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f806807 commit 55121af

16 files changed

Lines changed: 261 additions & 17 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@0xsequence/dapp-client': minor
3+
---
4+
5+
Add `isSponsored` for explicit sponsorship checks.
6+
7+
`DappClient.isSponsored(chainId, transactions)` and
8+
`ChainSessionManager.isSponsored(calls)` return `true` only when the relayer's
9+
`/FeeOptions` endpoint explicitly reports sponsorship. Any error, network
10+
failure, or absence of sponsorship returns `false`, so a `true` result is
11+
always safe to surface as "free gas" in UI.
12+
13+
Prefer this over inferring sponsorship from an empty `getFeeOptions` array — a
14+
swallowed `/FeeOptions` error also produces an empty array, so the inference
15+
can misclassify a failed quote as a real subsidy. The new method uses the
16+
positive `sponsored: boolean` signal from `@0xsequence/relayer`'s widened
17+
`feeOptions` return.
18+
19+
`getFeeOptions` is unchanged.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@0xsequence/relayer': minor
3+
---
4+
5+
Surface explicit sponsorship signal on `feeOptions` and an error marker on
6+
`feeOptions` / `feeTokens`.
7+
8+
- `RpcRelayer.feeOptions` now returns `sponsored: boolean`, forwarded from the
9+
server's `FeeOptionsReturn.sponsored`. The `Relayer` interface and all
10+
bundled implementations (`RpcRelayer`, `SequenceRelayer`, `LocalRelayer`,
11+
`EIP6963Relayer`, `PkRelayer`) carry the new field.
12+
- When `feeOptions` swallows a transport / server error it now returns
13+
`{ options: [], sponsored: false, failed: true }` (was `{ options: [] }`).
14+
- When `feeTokens` swallows an error it now returns
15+
`{ isFeeRequired: false, failed: true }` (was `{ isFeeRequired: false }`).
16+
17+
These changes are additive — existing consumers that ignore the new fields are
18+
unaffected. Consumers that classified sponsorship by "no fee option attached"
19+
should migrate to `sponsored === true` to distinguish a real subsidy from a
20+
swallowed `/FeeOptions` error.

.changeset/wdk-sponsored-signal.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@0xsequence/wallet-wdk': minor
3+
---
4+
5+
Carry `sponsored` / `failed` through `StandardRelayerOption`.
6+
7+
`StandardRelayerOption` now exposes two optional fields:
8+
9+
- `sponsored?: boolean` — populated from the relayer SDK's new `feeOptions`
10+
return field. `true` means the server confirmed an active sponsorship policy
11+
match; `false` means it did not (or the quote failed).
12+
- `failed?: boolean``true` when the relayer's `feeOptions` call was swallowed
13+
due to a transport or server error.
14+
15+
Both fields are populated on the empty-options construction branch and the
16+
per-option mapping branch in `transactions.ts`. The new `isStandardRelayerOption`
17+
and `isERC4337RelayerOption` runtime helpers are now re-exported from the
18+
package root for consumers that need to narrow `RelayerOption` before reading
19+
the new fields.
20+
21+
UI / wallet consumers that previously classified sponsorship by "no `feeOption`
22+
attached" should switch to `sponsored === true` so a real subsidy is no longer
23+
indistinguishable from a swallowed `/FeeOptions` error.

packages/services/relayer/src/relayer/relayer.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,20 @@ export interface Relayer {
1111

1212
isAvailable(wallet: Address.Address, chainId: number): Promise<boolean>
1313

14-
feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: FeeToken[]; paymentAddress?: Address.Address }>
14+
feeTokens(): Promise<{
15+
isFeeRequired: boolean
16+
tokens?: FeeToken[]
17+
paymentAddress?: Address.Address
18+
failed?: boolean
19+
}>
1520

1621
feeOptions(
1722
wallet: Address.Address,
1823
chainId: number,
1924
to: Address.Address,
2025
calls: Payload.Call[],
2126
data?: Hex.Hex,
22-
): Promise<{ options: FeeOption[]; quote?: FeeQuote }>
27+
): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }>
2328

2429
relay(to: Address.Address, data: Hex.Hex, chainId: number, quote?: FeeQuote): Promise<{ opHash: Hex.Hex }>
2530

packages/services/relayer/src/relayer/rpc-relayer/index.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,12 @@ export class RpcRelayer implements Relayer {
123123
return Promise.resolve(this.chainId === chainId)
124124
}
125125

126-
async feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: RpcFeeToken[]; paymentAddress?: Address.Address }> {
126+
async feeTokens(): Promise<{
127+
isFeeRequired: boolean
128+
tokens?: RpcFeeToken[]
129+
paymentAddress?: Address.Address
130+
failed?: boolean
131+
}> {
127132
try {
128133
const { isFeeRequired, tokens, paymentAddress } = await this.client.feeTokens()
129134
if (isFeeRequired) {
@@ -140,7 +145,7 @@ export class RpcRelayer implements Relayer {
140145
}
141146
} catch (e) {
142147
console.warn('RpcRelayer.feeTokens failed:', e)
143-
return { isFeeRequired: false }
148+
return { isFeeRequired: false, failed: true }
144149
}
145150
}
146151

@@ -150,7 +155,7 @@ export class RpcRelayer implements Relayer {
150155
to: Address.Address,
151156
calls: Payload.Call[],
152157
data?: Hex.Hex,
153-
): Promise<{ options: FeeOption[]; quote?: FeeQuote }> {
158+
): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }> {
154159
// IMPORTANT:
155160
// The relayer FeeOptions endpoint simulates `eth_call(to, data)`.
156161
// Callers that already built a wallet transaction should pass its `to` and `data`.
@@ -182,10 +187,10 @@ export class RpcRelayer implements Relayer {
182187
gasLimit: option.gasLimit,
183188
}))
184189

185-
return { options, quote }
190+
return { options, quote, sponsored: result.sponsored }
186191
} catch (e) {
187192
console.warn('RpcRelayer.feeOptions failed:', e)
188-
return { options: [] }
193+
return { options: [], sponsored: false, failed: true }
189194
}
190195
}
191196

packages/services/relayer/src/relayer/standard/eip6963.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ export class EIP6963Relayer implements Relayer {
2323
return this.relayer.isAvailable(wallet, chainId)
2424
}
2525

26-
feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: FeeToken[]; paymentAddress?: Address.Address }> {
26+
feeTokens(): Promise<{
27+
isFeeRequired: boolean
28+
tokens?: FeeToken[]
29+
paymentAddress?: Address.Address
30+
failed?: boolean
31+
}> {
2732
return this.relayer.feeTokens()
2833
}
2934

@@ -32,7 +37,7 @@ export class EIP6963Relayer implements Relayer {
3237
chainId: number,
3338
to: Address.Address,
3439
calls: Payload.Call[],
35-
): Promise<{ options: FeeOption[]; quote?: FeeQuote }> {
40+
): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }> {
3641
return this.relayer.feeOptions(wallet, chainId, to, calls)
3742
}
3843

packages/services/relayer/src/relayer/standard/local.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ export class LocalRelayer implements Relayer {
5656
return new LocalRelayer(new EIP1193ProviderAdapter(provider))
5757
}
5858

59-
feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: FeeToken[]; paymentAddress?: Address.Address }> {
59+
feeTokens(): Promise<{
60+
isFeeRequired: boolean
61+
tokens?: FeeToken[]
62+
paymentAddress?: Address.Address
63+
failed?: boolean
64+
}> {
6065
return Promise.resolve({
6166
isFeeRequired: false,
6267
})
@@ -67,8 +72,8 @@ export class LocalRelayer implements Relayer {
6772
_chainId: number,
6873
_to: Address.Address,
6974
_calls: Payload.Call[],
70-
): Promise<{ options: FeeOption[]; quote?: FeeQuote }> {
71-
return Promise.resolve({ options: [] })
75+
): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }> {
76+
return Promise.resolve({ options: [], sponsored: false })
7277
}
7378

7479
async relay(

packages/services/relayer/src/relayer/standard/pk-relayer.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,12 @@ export class PkRelayer implements Relayer {
107107
return providerChainId === chainId
108108
}
109109

110-
feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: FeeToken[]; paymentAddress?: Address.Address }> {
110+
feeTokens(): Promise<{
111+
isFeeRequired: boolean
112+
tokens?: FeeToken[]
113+
paymentAddress?: Address.Address
114+
failed?: boolean
115+
}> {
111116
return this.relayer.feeTokens()
112117
}
113118

@@ -116,7 +121,7 @@ export class PkRelayer implements Relayer {
116121
chainId: number,
117122
to: Address.Address,
118123
calls: Payload.Call[],
119-
): Promise<{ options: FeeOption[]; quote?: FeeQuote }> {
124+
): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }> {
120125
return this.relayer.feeOptions(wallet, chainId, to, calls)
121126
}
122127

packages/services/relayer/src/relayer/standard/sequence.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ export class SequenceRelayer implements Relayer {
1717
return true
1818
}
1919

20-
async feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: FeeToken[]; paymentAddress?: Address.Address }> {
20+
async feeTokens(): Promise<{
21+
isFeeRequired: boolean
22+
tokens?: FeeToken[]
23+
paymentAddress?: Address.Address
24+
failed?: boolean
25+
}> {
2126
const { isFeeRequired, tokens, paymentAddress } = await this.service.feeTokens()
2227
if (isFeeRequired) {
2328
Address.assert(paymentAddress)
@@ -39,17 +44,18 @@ export class SequenceRelayer implements Relayer {
3944
to: Address.Address,
4045
calls: Payload.Call[],
4146
transactionData?: Hex.Hex,
42-
): Promise<{ options: FeeOption[]; quote?: FeeQuote }> {
47+
): Promise<{ options: FeeOption[]; quote?: FeeQuote; sponsored: boolean; failed?: boolean }> {
4348
const execute = AbiFunction.from('function execute(bytes calldata _payload, bytes calldata _signature)')
4449
const payload = Payload.encode({ type: 'call', space: 0n, nonce: 0n, calls }, to)
4550
const signature = '0x0001' // TODO: use a stub signature
4651
const data = transactionData ?? AbiFunction.encodeData(execute, [Bytes.toHex(payload), signature])
4752

48-
const { options, quote } = await this.service.feeOptions({ wallet, to, data })
53+
const { options, quote, sponsored } = await this.service.feeOptions({ wallet, to, data })
4954

5055
return {
5156
options,
5257
quote: quote ? { _tag: 'FeeQuote', _quote: quote } : undefined,
58+
sponsored,
5359
}
5460
}
5561

packages/services/relayer/test/relayer/relayer.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ describe('Relayer', () => {
294294
vi.mocked(mockRelayer.feeOptions).mockResolvedValue({
295295
options: [],
296296
quote: undefined,
297+
sponsored: false,
297298
})
298299
vi.mocked(mockRelayer.relay).mockResolvedValue({
299300
opHash: TEST_OP_HASH,
@@ -375,6 +376,60 @@ describe('Relayer', () => {
375376
data: expectedData,
376377
})
377378
})
379+
380+
it('should propagate sponsored:true from the server', async () => {
381+
const fetchImpl = vi.fn(
382+
async () => new Response(JSON.stringify({ options: [], sponsored: true }), { status: 200 }),
383+
)
384+
const relayer = new Relayer.RpcRelayer('https://relayer.test', TEST_CHAIN_ID, 'https://rpc.test', fetchImpl)
385+
386+
const result = await relayer.feeOptions(TEST_WALLET_ADDRESS, TEST_CHAIN_ID, TEST_TO_ADDRESS, [mockCall])
387+
388+
expect(result.sponsored).toBe(true)
389+
expect(result.options).toEqual([])
390+
expect(result.failed).toBeUndefined()
391+
})
392+
393+
it('should propagate sponsored:false from the server', async () => {
394+
const fetchImpl = vi.fn(
395+
async () => new Response(JSON.stringify({ options: [], sponsored: false }), { status: 200 }),
396+
)
397+
const relayer = new Relayer.RpcRelayer('https://relayer.test', TEST_CHAIN_ID, 'https://rpc.test', fetchImpl)
398+
399+
const result = await relayer.feeOptions(TEST_WALLET_ADDRESS, TEST_CHAIN_ID, TEST_TO_ADDRESS, [mockCall])
400+
401+
expect(result.sponsored).toBe(false)
402+
expect(result.failed).toBeUndefined()
403+
})
404+
405+
it('should return sponsored:false and failed:true when the server errors', async () => {
406+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
407+
const fetchImpl = vi.fn(
408+
async () =>
409+
new Response(JSON.stringify({ error: 'Aborted', code: 1005, msg: 'simulation failed' }), { status: 400 }),
410+
)
411+
const relayer = new Relayer.RpcRelayer('https://relayer.test', TEST_CHAIN_ID, 'https://rpc.test', fetchImpl)
412+
413+
const result = await relayer.feeOptions(TEST_WALLET_ADDRESS, TEST_CHAIN_ID, TEST_TO_ADDRESS, [mockCall])
414+
415+
expect(result).toEqual({ options: [], sponsored: false, failed: true })
416+
expect(warn).toHaveBeenCalled()
417+
warn.mockRestore()
418+
})
419+
})
420+
421+
describe('RpcRelayer.feeTokens', () => {
422+
it('should return failed:true when the server errors', async () => {
423+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
424+
const fetchImpl = vi.fn(async () => new Response('boom', { status: 500 }))
425+
const relayer = new Relayer.RpcRelayer('https://relayer.test', TEST_CHAIN_ID, 'https://rpc.test', fetchImpl)
426+
427+
const result = await relayer.feeTokens()
428+
429+
expect(result).toEqual({ isFeeRequired: false, failed: true })
430+
expect(warn).toHaveBeenCalled()
431+
warn.mockRestore()
432+
})
378433
})
379434

380435
describe('Type compatibility', () => {

0 commit comments

Comments
 (0)