Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/dapp-client-is-sponsored.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions .changeset/relayer-sponsored-signal.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions .changeset/wdk-sponsored-signal.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 7 additions & 2 deletions packages/services/relayer/src/relayer/relayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@ export interface Relayer {

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

feeTokens(): Promise<{ isFeeRequired: boolean; tokens?: FeeToken[]; paymentAddress?: Address.Address }>
feeTokens(): Promise<{
isFeeRequired: boolean
tokens?: FeeToken[]
paymentAddress?: Address.Address
failed?: boolean
}>

feeOptions(
wallet: Address.Address,
chainId: number,
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 }>

Expand Down
15 changes: 10 additions & 5 deletions packages/services/relayer/src/relayer/rpc-relayer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 }
}
}

Expand All @@ -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`.
Expand Down Expand Up @@ -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 }
}
}

Expand Down
9 changes: 7 additions & 2 deletions packages/services/relayer/src/relayer/standard/eip6963.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -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)
}

Expand Down
11 changes: 8 additions & 3 deletions packages/services/relayer/src/relayer/standard/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -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)
}

Expand Down
12 changes: 9 additions & 3 deletions packages/services/relayer/src/relayer/standard/sequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
}
}

Expand Down
55 changes: 55 additions & 0 deletions packages/services/relayer/test/relayer/relayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
48 changes: 48 additions & 0 deletions packages/wallet/dapp-client/src/ChainSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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.
Expand Down
24 changes: 24 additions & 0 deletions packages/wallet/dapp-client/src/DappClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
const chainSessionManager = await this.getOrInitializeChainManager(chainId)
Comment thread
taylanpince marked this conversation as resolved.
return await chainSessionManager.isSponsored(transactions)
}

/**
* Fetches fee tokens for a chain.
* @returns A promise that resolves with the fee tokens response. {@link GetFeeTokensResponse}
Expand Down
Loading
Loading