accounts 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/core/Adapter.d.ts +6 -0
  3. package/dist/core/Adapter.d.ts.map +1 -1
  4. package/dist/core/Adapter.js.map +1 -1
  5. package/dist/core/Client.d.ts.map +1 -1
  6. package/dist/core/Client.js +16 -2
  7. package/dist/core/Client.js.map +1 -1
  8. package/dist/core/Provider.d.ts.map +1 -1
  9. package/dist/core/Provider.js +15 -0
  10. package/dist/core/Provider.js.map +1 -1
  11. package/dist/core/Remote.d.ts.map +1 -1
  12. package/dist/core/Remote.js +0 -6
  13. package/dist/core/Remote.js.map +1 -1
  14. package/dist/core/Schema.d.ts +85 -0
  15. package/dist/core/Schema.d.ts.map +1 -1
  16. package/dist/core/Schema.js +1 -0
  17. package/dist/core/Schema.js.map +1 -1
  18. package/dist/core/adapters/dialog.d.ts.map +1 -1
  19. package/dist/core/adapters/dialog.js +8 -1
  20. package/dist/core/adapters/dialog.js.map +1 -1
  21. package/dist/core/adapters/local.d.ts.map +1 -1
  22. package/dist/core/adapters/local.js +19 -5
  23. package/dist/core/adapters/local.js.map +1 -1
  24. package/dist/core/zod/rpc.d.ts +71 -0
  25. package/dist/core/zod/rpc.d.ts.map +1 -1
  26. package/dist/core/zod/rpc.js +25 -0
  27. package/dist/core/zod/rpc.js.map +1 -1
  28. package/dist/server/internal/handlers/relay.js +85 -3
  29. package/dist/server/internal/handlers/relay.js.map +1 -1
  30. package/package.json +4 -4
  31. package/src/core/Adapter.ts +12 -0
  32. package/src/core/Client.ts +14 -2
  33. package/src/core/Provider.test.ts +103 -4
  34. package/src/core/Provider.ts +19 -0
  35. package/src/core/Remote.ts +0 -7
  36. package/src/core/Schema.test-d.ts +18 -1
  37. package/src/core/Schema.ts +1 -0
  38. package/src/core/adapters/dialog.ts +9 -1
  39. package/src/core/adapters/local.ts +22 -5
  40. package/src/core/zod/rpc.ts +28 -0
  41. package/src/server/internal/handlers/relay.test.ts +121 -0
  42. package/src/server/internal/handlers/relay.ts +105 -3
@@ -22,8 +22,9 @@ export function fromChainId(
22
22
  const { chains, feePayer: feePayerOption, provider, store } = options
23
23
  const feePayerUrl = (() => {
24
24
  if (feePayerOption === false) return undefined
25
- if (typeof feePayerOption === 'string') return feePayerOption
26
- return feePayerOption?.url
25
+ if (typeof feePayerOption === 'string') return normalizeFeePayerUrl(feePayerOption)
26
+ if (feePayerOption?.url) return normalizeFeePayerUrl(feePayerOption.url)
27
+ return undefined
27
28
  })()
28
29
  const precedence = (() => {
29
30
  if (typeof feePayerOption === 'object' && feePayerOption !== null)
@@ -87,6 +88,17 @@ function providerTransport(provider: ox_Provider.Provider, base: Transport): Tra
87
88
  }
88
89
  }
89
90
 
91
+ /**
92
+ * Resolves a fee payer URL to an absolute URL string. Relative paths (e.g.
93
+ * `/relay`) are resolved against `window.location.origin` when running in a
94
+ * browser; on the server, relative paths are returned as-is.
95
+ */
96
+ function normalizeFeePayerUrl(url: string): string {
97
+ if (url.startsWith('http://') || url.startsWith('https://')) return url
98
+ if (typeof window !== 'undefined') return new URL(url, window.location.origin).href
99
+ return url
100
+ }
101
+
90
102
  function feePayerTransport(
91
103
  base: Transport,
92
104
  url: string,
@@ -642,6 +642,28 @@ describe.each(adapters)('$name', ({ adapter }: (typeof adapters)[number]) => {
642
642
  })
643
643
  })
644
644
 
645
+ describe('wallet_send', () => {
646
+ test('error: throws UnsupportedMethodError when adapter has no send action', async () => {
647
+ const provider = Provider.create({ adapter: adapter(), chains: [chain] })
648
+ await connect(provider)
649
+
650
+ await expect(
651
+ provider.request({
652
+ method: 'wallet_send',
653
+ params: [
654
+ {
655
+ to: '0x0000000000000000000000000000000000000001',
656
+ token: Addresses.pathUsd,
657
+ value: '1',
658
+ },
659
+ ],
660
+ }),
661
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
662
+ `[Provider.UnsupportedMethodError: \`send\` not supported by adapter.]`,
663
+ )
664
+ })
665
+ })
666
+
645
667
  describe('wallet_getCapabilities', () => {
646
668
  test('default: returns atomic supported for all chains', async () => {
647
669
  const provider = Provider.create({ adapter: adapter() })
@@ -1214,24 +1236,95 @@ describe.each(adapters)('$name', ({ adapter }: (typeof adapters)[number]) => {
1214
1236
  })
1215
1237
 
1216
1238
  describe('wallet_revokeAccessKey', () => {
1217
- test('default: revokes a granted access key', async () => {
1239
+ test('default: revokes a granted access key on-chain', async () => {
1218
1240
  const provider = Provider.create({ adapter: adapter(), chains: [chain] })
1219
1241
  await connect(provider)
1220
1242
 
1221
1243
  const connected = (await provider.request({ method: 'eth_accounts' }))[0]!
1244
+ await fund(connected)
1245
+
1222
1246
  const { keyAuthorization } = await provider.request({
1223
1247
  method: 'wallet_authorizeAccessKey',
1224
1248
  params: [{ expiry: Expiry.days(1) }],
1225
1249
  })
1226
1250
 
1251
+ // Send a tx to register the key on-chain via keyAuthorization.
1252
+ await provider.request({
1253
+ method: 'eth_sendTransactionSync',
1254
+ params: [{ calls: [transferCall] }],
1255
+ })
1256
+
1257
+ // Key should exist on-chain before revocation.
1258
+ const client = getClient()
1259
+ const before = await Actions.accessKey.getMetadata(client, {
1260
+ account: connected,
1261
+ accessKey: keyAuthorization.address,
1262
+ })
1263
+ expect(before.isRevoked).toBe(false)
1264
+
1227
1265
  await provider.request({
1228
1266
  method: 'wallet_revokeAccessKey',
1229
1267
  params: [{ address: connected, accessKeyAddress: keyAuthorization.address }],
1230
1268
  })
1231
1269
 
1232
- // After revoking, sendTransactionSync should use root key (still works)
1233
- const address = (await provider.request({ method: 'eth_accounts' }))[0]!
1234
- await fund(address)
1270
+ // Key should be revoked on-chain.
1271
+ const after = await Actions.accessKey.getMetadata(client, {
1272
+ account: connected,
1273
+ accessKey: keyAuthorization.address,
1274
+ })
1275
+ expect(after.isRevoked).toBe(true)
1276
+ })
1277
+
1278
+ test('behavior: removes key from local store', async () => {
1279
+ const provider = Provider.create({ adapter: adapter(), chains: [chain] })
1280
+ await connect(provider)
1281
+
1282
+ const connected = (await provider.request({ method: 'eth_accounts' }))[0]!
1283
+ await fund(connected)
1284
+
1285
+ const { keyAuthorization } = await provider.request({
1286
+ method: 'wallet_authorizeAccessKey',
1287
+ params: [{ expiry: Expiry.days(1) }],
1288
+ })
1289
+
1290
+ // Register the key on-chain.
1291
+ await provider.request({
1292
+ method: 'eth_sendTransactionSync',
1293
+ params: [{ calls: [transferCall] }],
1294
+ })
1295
+
1296
+ expect(provider.store.getState().accessKeys).toHaveLength(1)
1297
+
1298
+ await provider.request({
1299
+ method: 'wallet_revokeAccessKey',
1300
+ params: [{ address: connected, accessKeyAddress: keyAuthorization.address }],
1301
+ })
1302
+
1303
+ expect(provider.store.getState().accessKeys).toMatchInlineSnapshot(`[]`)
1304
+ })
1305
+
1306
+ test('behavior: root key still works after revoking access key', async () => {
1307
+ const provider = Provider.create({ adapter: adapter(), chains: [chain] })
1308
+ await connect(provider)
1309
+
1310
+ const connected = (await provider.request({ method: 'eth_accounts' }))[0]!
1311
+ await fund(connected)
1312
+
1313
+ const { keyAuthorization } = await provider.request({
1314
+ method: 'wallet_authorizeAccessKey',
1315
+ params: [{ expiry: Expiry.days(1) }],
1316
+ })
1317
+
1318
+ // Register the key on-chain, then revoke it.
1319
+ await provider.request({
1320
+ method: 'eth_sendTransactionSync',
1321
+ params: [{ calls: [transferCall] }],
1322
+ })
1323
+
1324
+ await provider.request({
1325
+ method: 'wallet_revokeAccessKey',
1326
+ params: [{ address: connected, accessKeyAddress: keyAuthorization.address }],
1327
+ })
1235
1328
 
1236
1329
  const receipt = await provider.request({
1237
1330
  method: 'eth_sendTransactionSync',
@@ -1292,6 +1385,12 @@ describe.each(adapters)('$name', ({ adapter }: (typeof adapters)[number]) => {
1292
1385
  })
1293
1386
  expect(provider.store.getState().accessKeys).toHaveLength(1)
1294
1387
 
1388
+ // Send a tx to register the key on-chain via keyAuthorization.
1389
+ await provider.request({
1390
+ method: 'eth_sendTransactionSync',
1391
+ params: [{ calls: [transferCall] }],
1392
+ })
1393
+
1295
1394
  // Revoke the access key on-chain so the node will reject it.
1296
1395
  const { accessKeys } = provider.store.getState()
1297
1396
  const accessKeyAddress = accessKeys[0]!.address
@@ -619,6 +619,25 @@ export function create(options: create.Options = {}): create.ReturnType {
619
619
  )) satisfies Rpc.wallet_deposit.Encoded['returns']
620
620
  }
621
621
 
622
+ case 'wallet_send': {
623
+ assertConnected()
624
+ if (!actions.send)
625
+ throw new ox_Provider.UnsupportedMethodError({
626
+ message: '`send` not supported by adapter.',
627
+ })
628
+ const decoded = request._decoded.params?.[0] ?? {}
629
+ const parameters = {
630
+ ...decoded,
631
+ ...(typeof decoded.feePayer !== 'undefined'
632
+ ? { feePayer: resolveFeePayer(decoded.feePayer) }
633
+ : {}),
634
+ } as Adapter.send.Parameters
635
+ return (await actions.send(
636
+ parameters,
637
+ request,
638
+ )) satisfies Rpc.wallet_send.Encoded['returns']
639
+ }
640
+
622
641
  case 'wallet_switchEthereumChain': {
623
642
  const { chainId } = request._decoded.params[0]
624
643
  if (!chains.some((c) => c.id === chainId))
@@ -210,15 +210,8 @@ export function create(options: create.Options): Remote {
210
210
  if (typeof window !== 'undefined') {
211
211
  const params = new URLSearchParams(window.location.search)
212
212
  const mode = params.get('mode') as State['mode']
213
- const chainId = Number(params.get('chainId'))
214
213
 
215
214
  if (mode) store.setState({ mode })
216
-
217
- if (chainId && provider.store.getState().chainId !== chainId)
218
- provider.request({
219
- method: 'wallet_switchEthereumChain',
220
- params: [{ chainId: Hex.fromNumber(chainId) }],
221
- })
222
215
  }
223
216
  },
224
217
 
@@ -121,6 +121,22 @@ describe('Encoded', () => {
121
121
  }>()
122
122
  })
123
123
 
124
+ test('wallet_send', () => {
125
+ expectTypeOf<Rpc.wallet_send.Encoded>().toMatchTypeOf<{
126
+ method: 'wallet_send'
127
+ params:
128
+ | readonly [
129
+ {
130
+ to?: Hex | undefined
131
+ token?: Hex | undefined
132
+ value?: string | undefined
133
+ },
134
+ ]
135
+ | undefined
136
+ returns: { receipt: { transactionHash: Hex } }
137
+ }>()
138
+ })
139
+
124
140
  test('wallet_switchEthereumChain', () => {
125
141
  expectTypeOf<Rpc.wallet_switchEthereumChain.Encoded>().toEqualTypeOf<{
126
142
  method: 'wallet_switchEthereumChain'
@@ -159,7 +175,7 @@ describe('Ox', () => {
159
175
  describe('Viem', () => {
160
176
  test('is a tuple of all provider methods', () => {
161
177
  expectTypeOf<Schema.Viem[0]['Method']>().toEqualTypeOf<'eth_accounts'>()
162
- expectTypeOf<Schema.Viem[18]['Method']>().toEqualTypeOf<'wallet_switchEthereumChain'>()
178
+ expectTypeOf<Schema.Viem[19]['Method']>().toEqualTypeOf<'wallet_switchEthereumChain'>()
163
179
  })
164
180
  })
165
181
 
@@ -186,6 +202,7 @@ describe('Request', () => {
186
202
  | 'wallet_deposit'
187
203
  | 'wallet_getBalances'
188
204
  | 'wallet_revokeAccessKey'
205
+ | 'wallet_send'
189
206
  >()
190
207
  })
191
208
 
@@ -93,6 +93,7 @@ export const schema = from([
93
93
  Rpc.wallet_getCallsStatus.schema,
94
94
  Rpc.wallet_getCapabilities.schema,
95
95
  Rpc.wallet_revokeAccessKey.schema,
96
+ Rpc.wallet_send.schema,
96
97
  Rpc.wallet_sendCalls.schema,
97
98
  Rpc.wallet_switchEthereumChain.schema,
98
99
  ])
@@ -166,7 +166,8 @@ export function dialog(options: dialog.Options = {}): Adapter.Adapter {
166
166
  const result = await fn(account, keyAuthorization ?? undefined)
167
167
  AccessKey.removePending(account, { store })
168
168
  return result
169
- } catch {
169
+ } catch (err) {
170
+ console.warn('[accounts] silent sign with access key failed, removing key:', err)
170
171
  AccessKey.remove(account, { store })
171
172
  return undefined
172
173
  }
@@ -395,6 +396,13 @@ export function dialog(options: dialog.Options = {}): Adapter.Adapter {
395
396
  return await provider.request(request)
396
397
  },
397
398
 
399
+ async send(params, request) {
400
+ return await provider.request({
401
+ ...request,
402
+ params: [z.encode(Rpc.wallet_send.parameters, params)] as const,
403
+ })
404
+ },
405
+
398
406
  async disconnect() {
399
407
  store.setState({ accessKeys: [], accounts: [], activeAccount: 0 })
400
408
  },
@@ -1,7 +1,8 @@
1
1
  import { Address as ox_Address, Hex, Provider as ox_Provider, PublicKey, WebCryptoP256 } from 'ox'
2
2
  import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo'
3
+ import { BaseError } from 'viem'
3
4
  import { prepareTransactionRequest } from 'viem/actions'
4
- import { Account as TempoAccount } from 'viem/tempo'
5
+ import { Account as TempoAccount, Actions } from 'viem/tempo'
5
6
 
6
7
  import * as AccessKey from '../AccessKey.js'
7
8
  import * as Account from '../Account.js'
@@ -184,10 +185,26 @@ export function local(options: local.Options): Adapter.Adapter {
184
185
  return { accounts, email, keyAuthorization, signature: signature_, username }
185
186
  },
186
187
  async revokeAccessKey(parameters) {
187
- AccessKey.revoke({
188
- address: parameters.address,
189
- store,
190
- })
188
+ const account = getAccount({ accessKey: false, signable: true })
189
+ const client = getClient()
190
+ try {
191
+ await Actions.accessKey.revoke(client, {
192
+ account,
193
+ accessKey: parameters.accessKeyAddress,
194
+ } as never)
195
+ } catch (error) {
196
+ const isKeyNotFound =
197
+ error instanceof BaseError &&
198
+ !!error.walk(
199
+ (e) => (e as { data?: { errorName?: string } }).data?.errorName === 'KeyNotFound',
200
+ )
201
+ if (!isKeyNotFound) throw error
202
+ }
203
+ store.setState((state) => ({
204
+ accessKeys: state.accessKeys.filter(
205
+ (a) => a.address?.toLowerCase() !== parameters.accessKeyAddress.toLowerCase(),
206
+ ),
207
+ }))
191
208
  },
192
209
  async signPersonalMessage({ data, address }) {
193
210
  const account = getAccount({ address, signable: true })
@@ -531,6 +531,34 @@ export namespace wallet_getCallsStatus {
531
531
  export type Decoded = Schema.Decoded<typeof schema>
532
532
  }
533
533
 
534
+ export namespace wallet_send {
535
+ /** Parameters object for `wallet_send`. */
536
+ export const parameters = z.object({
537
+ /**
538
+ * Fee payer override. `false` to disable the wallet's default fee
539
+ * payer, a URL string to use a custom fee payer service.
540
+ */
541
+ feePayer: z.optional(z.union([z.boolean(), z.string()])),
542
+ /** Recipient address to pre-fill. */
543
+ to: z.optional(u.address()),
544
+ /** Token contract address to pre-fill. Omit to let the user choose. */
545
+ token: z.optional(u.address()),
546
+ /** Human-readable amount to pre-fill (e.g. "1.5"). */
547
+ value: z.optional(z.string()),
548
+ })
549
+
550
+ export const schema = Schema.defineItem({
551
+ method: z.literal('wallet_send'),
552
+ params: z.optional(z.readonly(z.tuple([parameters]))),
553
+ returns: z.object({
554
+ /** Receipt of the submitted send. */
555
+ receipt,
556
+ }),
557
+ })
558
+ export type Encoded = Schema.Encoded<typeof schema>
559
+ export type Decoded = Schema.Decoded<typeof schema>
560
+ }
561
+
534
562
  export namespace wallet_switchEthereumChain {
535
563
  export const schema = Schema.defineItem({
536
564
  method: z.literal('wallet_switchEthereumChain'),
@@ -281,6 +281,127 @@ describe('behavior: with app-provided feePayer URL', () => {
281
281
  })
282
282
  })
283
283
 
284
+ describe('behavior: with app-provided feePayer URL + autoSwap', () => {
285
+ let appServer: Server
286
+ let walletServer: Server
287
+ let client: ReturnType<typeof getClient<typeof chain>>
288
+
289
+ beforeAll(async () => {
290
+ // App relay sponsors fees AND has `features: 'all'` so it can recover
291
+ // from InsufficientBalance via autoSwap.
292
+ appServer = await createServer(
293
+ relay({
294
+ chains: [chain],
295
+ features: 'all',
296
+ transports: { [chain.id]: http() },
297
+ feePayer: {
298
+ account: feePayerAccount,
299
+ name: 'App Sponsor',
300
+ url: 'https://app.example.com',
301
+ },
302
+ }).listener,
303
+ )
304
+
305
+ // Wallet relay forwards to the app relay; also has features:'all' so its
306
+ // own fill() can detect upstream `capabilities.error` as InsufficientBalance.
307
+ walletServer = await createServer(
308
+ relay({
309
+ chains: [chain],
310
+ features: 'all',
311
+ transports: { [chain.id]: http() },
312
+ }).listener,
313
+ )
314
+
315
+ client = getClient({ transport: http(walletServer.url) })
316
+ })
317
+
318
+ afterAll(() => {
319
+ appServer.close()
320
+ walletServer.close()
321
+ })
322
+
323
+ test('behavior: autoSwap recovers when external feePayer surfaces InsufficientBalance', async () => {
324
+ const sender = accounts[6]!
325
+
326
+ // Token pair + DEX liquidity. Use alphaUsd as the quote token so the
327
+ // relay can swap alphaUsd → base to cover the deficit.
328
+ const rpc = getClient({ account: accounts[0]! })
329
+ const { token: base } = await Actions.token.createSync(rpc, {
330
+ name: 'External Swap Base',
331
+ symbol: 'EXTBASE',
332
+ currency: 'USD',
333
+ quoteToken: addresses.alphaUsd,
334
+ })
335
+ await sendTransactionSync(rpc, {
336
+ calls: [
337
+ Actions.token.grantRoles.call({ token: base, role: 'issuer', to: rpc.account!.address }),
338
+ Actions.token.mint.call({
339
+ token: base,
340
+ to: rpc.account!.address,
341
+ amount: parseUnits('10000', 6),
342
+ }),
343
+ Actions.token.mint.call({
344
+ token: addresses.alphaUsd,
345
+ to: rpc.account!.address,
346
+ amount: parseUnits('10000', 6),
347
+ }),
348
+ Actions.token.approve.call({
349
+ token: base,
350
+ spender: Addresses.stablecoinDex,
351
+ amount: parseUnits('10000', 6),
352
+ }),
353
+ Actions.token.approve.call({
354
+ token: addresses.alphaUsd,
355
+ spender: Addresses.stablecoinDex,
356
+ amount: parseUnits('10000', 6),
357
+ }),
358
+ ],
359
+ })
360
+ await Actions.dex.createPairSync(rpc, { base })
361
+ await Actions.dex.placeSync(rpc, {
362
+ token: base,
363
+ amount: parseUnits('500', 6),
364
+ type: 'sell',
365
+ tick: Tick.fromPrice('1.001'),
366
+ })
367
+
368
+ // Give sender alphaUsd (fee + swap source) but NO base tokens.
369
+ await Actions.token.mintSync(rpc, {
370
+ token: addresses.alphaUsd,
371
+ amount: parseUnits('1000', 6),
372
+ to: sender.address,
373
+ })
374
+ await Actions.fee.setUserToken(getClient({ account: sender }), { token: addresses.alphaUsd })
375
+
376
+ // Sender attempts to transfer base via the wallet relay, which forwards
377
+ // to the app relay. The app relay returns 200 with capabilities.error =
378
+ // InsufficientBalance and a stub tx; the wallet relay must convert that
379
+ // into a synthetic throw so its own fill() autoSwap branch can recover.
380
+ const transferAmount = parseUnits('5', 6)
381
+ const result = await fillTransaction(client, {
382
+ account: sender.address,
383
+ ...Actions.token.transfer.call({
384
+ token: base,
385
+ to: accounts[7]!.address,
386
+ amount: transferAmount,
387
+ }),
388
+ feePayer: appServer.url as never,
389
+ })
390
+
391
+ const { transaction, capabilities } = result
392
+
393
+ // Tx is filled with the swap calls prepended (approve + buy + transfer).
394
+ expect(transaction.calls).toHaveLength(3)
395
+ expect(transaction.feePayerSignature).toBeDefined()
396
+
397
+ // autoSwap metadata is surfaced.
398
+ expect(capabilities?.autoSwap?.slippage).toBe(0.05)
399
+ expect(capabilities?.autoSwap?.maxIn.symbol).toBe('AlphaUSD')
400
+ expect(capabilities?.autoSwap?.minOut.symbol).toBe('EXTBASE')
401
+ expect(capabilities?.autoSwap?.minOut.formatted).toBe('5')
402
+ })
403
+ })
404
+
284
405
  describe('behavior: chainId path parameter', () => {
285
406
  let server: Server
286
407
  let client: ReturnType<typeof getClient<typeof chain>>
@@ -573,10 +573,36 @@ async function fill(
573
573
  // @ts-expect-error
574
574
  if (result.tx.gas && request.feePayer && !result.tx.feePayerSignature)
575
575
  result.tx.gas = Hex.fromNumber(BigInt(result.tx.gas) + 20_000n)
576
- const sponsor = (result as Record<string, any>).capabilities?.sponsor as
576
+ const upstreamCapabilities = (result as { capabilities?: Record<string, unknown> }).capabilities
577
+ const sponsor = upstreamCapabilities?.sponsor as
577
578
  | { address: Address; name?: string; url?: string }
578
579
  | undefined
579
- return { transaction: Utils.normalizeTempoTransaction(result.tx), sponsor }
580
+ // External fee-payer relays surface chain reverts (e.g. InsufficientBalance)
581
+ // inside `capabilities.error` with a stub `tx` instead of throwing. Detect
582
+ // that here and re-throw so the autoSwap branch below can recover the same
583
+ // way it does for the direct-chain path.
584
+ const upstreamError = upstreamCapabilities?.error as
585
+ | { errorName?: string; message?: string; data?: `0x${string}` }
586
+ | undefined
587
+ if (upstreamError?.errorName === 'InsufficientBalance') {
588
+ const synthetic = new Error(upstreamError.message ?? 'InsufficientBalance')
589
+ synthetic.name = 'UpstreamRevertError'
590
+ ;(synthetic as { data?: `0x${string}` | undefined }).data = upstreamError.data
591
+ throw synthetic
592
+ }
593
+ // Reconstruct a `swap` shape from upstream's `capabilities.autoSwap` so the
594
+ // wallet relay's outer code can re-resolve autoSwap metadata locally —
595
+ // otherwise upstream-driven swaps are silently dropped from the response.
596
+ const swap = extractSwapFromCapabilities(upstreamCapabilities?.autoSwap)
597
+ // The chain's `eth_fillTransaction` doesn't echo back `calls`, so merge
598
+ // them in from the original request before normalizing — otherwise the
599
+ // typed envelope built for sponsorship signing throws CallsEmptyError.
600
+ const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, request)
601
+ return {
602
+ transaction: Utils.normalizeTempoTransaction(mergedTx),
603
+ sponsor,
604
+ ...(swap ? { swap } : {}),
605
+ }
580
606
  } catch (error) {
581
607
  if (!(error instanceof Error)) throw error
582
608
  if (!autoSwap) throw error
@@ -613,8 +639,12 @@ async function fill(
613
639
  const sponsor = (result as Record<string, any>).capabilities?.sponsor as
614
640
  | { address: Address; name?: string; url?: string }
615
641
  | undefined
642
+ const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, {
643
+ ...request,
644
+ calls: [...swapCalls, ...originalCalls],
645
+ })
616
646
  return {
617
- transaction: Utils.normalizeTempoTransaction(result.tx),
647
+ transaction: Utils.normalizeTempoTransaction(mergedTx),
618
648
  sponsor,
619
649
  swap: {
620
650
  calls: swapCalls,
@@ -1015,3 +1045,75 @@ function buildSwapCalls(
1015
1045
  { to: buy.to, data: buy.data, value: 0n },
1016
1046
  ] as const
1017
1047
  }
1048
+
1049
+ /**
1050
+ * Merges the original fill request into the result tx. The chain's
1051
+ * `eth_fillTransaction` returns only the "filled" gas/nonce/fee fields and
1052
+ * omits envelope inputs like `calls`, `chainId`, `validBefore`, `nonceKey`,
1053
+ * `keyData`, `keyType`, `feePayer`. Without these the typed Tempo envelope
1054
+ * built for sponsorship signing throws `CallsEmptyError` or
1055
+ * `Cannot convert undefined to a BigInt` when serializing.
1056
+ *
1057
+ * Result fields take precedence (they are the chain's authoritative filled
1058
+ * values); request fields fill in everything else. Calls are normalized
1059
+ * separately so legacy `to`/`data`/`value` requests are also supported.
1060
+ */
1061
+ function mergeCallsFromRequest(
1062
+ resultTx: Record<string, unknown>,
1063
+ request: Record<string, unknown>,
1064
+ ): Record<string, unknown> {
1065
+ const merged: Record<string, unknown> = { ...request, ...resultTx }
1066
+ const resultCalls = resultTx.calls
1067
+ if (Array.isArray(resultCalls) && resultCalls.length > 0) return merged
1068
+
1069
+ const reqCalls = request.calls
1070
+ if (Array.isArray(reqCalls) && reqCalls.length > 0) {
1071
+ merged.calls = reqCalls
1072
+ return merged
1073
+ }
1074
+
1075
+ const { to, data, value } = request
1076
+ if (typeof to === 'undefined' && typeof data === 'undefined' && typeof value === 'undefined')
1077
+ return merged
1078
+
1079
+ merged.calls = [
1080
+ {
1081
+ ...(typeof to !== 'undefined' ? { to } : {}),
1082
+ ...(typeof data !== 'undefined' ? { data } : {}),
1083
+ ...(typeof value !== 'undefined' ? { value } : {}),
1084
+ },
1085
+ ]
1086
+ return merged
1087
+ }
1088
+
1089
+ /**
1090
+ * Reconstructs a `swap` shape (matching the inner autoSwap branch's return
1091
+ * value) from an upstream relay's `capabilities.autoSwap`. Used so a wallet
1092
+ * relay forwarding to an external feePayer URL can re-emit autoSwap metadata
1093
+ * locally without losing track of the upstream's swap.
1094
+ */
1095
+ function extractSwapFromCapabilities(autoSwap: unknown):
1096
+ | {
1097
+ calls: readonly { to: Address; data: `0x${string}` }[]
1098
+ tokenIn: Address
1099
+ tokenOut: Address
1100
+ amountOut: bigint
1101
+ maxAmountIn: bigint
1102
+ }
1103
+ | undefined {
1104
+ if (!autoSwap || typeof autoSwap !== 'object') return undefined
1105
+ const a = autoSwap as {
1106
+ calls?: readonly { to: Address; data: `0x${string}` }[]
1107
+ maxIn?: { token?: Address; value?: `0x${string}` }
1108
+ minOut?: { token?: Address; value?: `0x${string}` }
1109
+ }
1110
+ if (!a.calls || !a.maxIn?.token || !a.maxIn.value || !a.minOut?.token || !a.minOut.value)
1111
+ return undefined
1112
+ return {
1113
+ calls: a.calls,
1114
+ tokenIn: a.maxIn.token,
1115
+ tokenOut: a.minOut.token,
1116
+ amountOut: BigInt(a.minOut.value),
1117
+ maxAmountIn: BigInt(a.maxIn.value),
1118
+ }
1119
+ }