tapimo 0.0.2 → 0.1.0

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 (265) hide show
  1. package/README.md +11 -2
  2. package/dist/ApiKey.d.ts +12 -11
  3. package/dist/ApiKey.d.ts.map +1 -1
  4. package/dist/ApiKey.js +10 -12
  5. package/dist/ApiKey.js.map +1 -1
  6. package/dist/ApiKeys.d.ts +139 -0
  7. package/dist/ApiKeys.d.ts.map +1 -0
  8. package/dist/ApiKeys.js +166 -0
  9. package/dist/ApiKeys.js.map +1 -0
  10. package/dist/App.d.ts +920 -910
  11. package/dist/App.d.ts.map +1 -1
  12. package/dist/App.js +36 -12
  13. package/dist/App.js.map +1 -1
  14. package/dist/Handler.d.ts +86 -0
  15. package/dist/Handler.d.ts.map +1 -0
  16. package/dist/Handler.js +83 -0
  17. package/dist/Handler.js.map +1 -0
  18. package/dist/Scope.d.ts +4 -1
  19. package/dist/Scope.d.ts.map +1 -1
  20. package/dist/Scope.js +5 -1
  21. package/dist/Scope.js.map +1 -1
  22. package/dist/Webhooks.d.ts +3 -5
  23. package/dist/Webhooks.d.ts.map +1 -1
  24. package/dist/Webhooks.js +9 -2
  25. package/dist/Webhooks.js.map +1 -1
  26. package/dist/admin/Access.d.ts +1 -3
  27. package/dist/admin/Access.d.ts.map +1 -1
  28. package/dist/admin/Access.js +1 -3
  29. package/dist/admin/Access.js.map +1 -1
  30. package/dist/admin/App.d.ts +98 -209
  31. package/dist/admin/App.d.ts.map +1 -1
  32. package/dist/admin/App.js +2 -4
  33. package/dist/admin/App.js.map +1 -1
  34. package/dist/admin/apps/api-keys.d.ts +7 -5
  35. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  36. package/dist/admin/apps/api-keys.js +8 -9
  37. package/dist/admin/apps/api-keys.js.map +1 -1
  38. package/dist/admin/apps/scopes.d.ts +3 -0
  39. package/dist/admin/apps/scopes.d.ts.map +1 -1
  40. package/dist/admin/apps/verified-tokens.d.ts +101 -215
  41. package/dist/admin/apps/verified-tokens.d.ts.map +1 -1
  42. package/dist/admin/apps/verified-tokens.js +17 -38
  43. package/dist/admin/apps/verified-tokens.js.map +1 -1
  44. package/dist/admin/ui.generated.d.ts +1 -1
  45. package/dist/admin/ui.generated.js +1 -1
  46. package/dist/apps/balances.d.ts +2 -2
  47. package/dist/apps/receipts.d.ts +43 -43
  48. package/dist/apps/tokens.d.ts +454 -454
  49. package/dist/apps/transactions.d.ts +43 -43
  50. package/dist/apps/transfers.d.ts +29 -29
  51. package/dist/apps/webhooks.d.ts +5 -2
  52. package/dist/apps/webhooks.d.ts.map +1 -1
  53. package/dist/apps/webhooks.js +38 -34
  54. package/dist/apps/webhooks.js.map +1 -1
  55. package/dist/{Cli.d.ts → cli/Cli.d.ts} +13 -2
  56. package/dist/cli/Cli.d.ts.map +1 -0
  57. package/dist/cli/Cli.js +19 -0
  58. package/dist/cli/Cli.js.map +1 -0
  59. package/dist/cli/bin.d.ts +3 -0
  60. package/dist/cli/bin.d.ts.map +1 -0
  61. package/dist/cli/bin.js +4 -0
  62. package/dist/cli/bin.js.map +1 -0
  63. package/dist/cli/commands/admin.d.ts +15 -0
  64. package/dist/cli/commands/admin.d.ts.map +1 -0
  65. package/dist/cli/commands/admin.js +60 -0
  66. package/dist/cli/commands/admin.js.map +1 -0
  67. package/dist/cloudflare.d.ts +7 -0
  68. package/dist/cloudflare.d.ts.map +1 -1
  69. package/dist/cloudflare.js +9 -0
  70. package/dist/cloudflare.js.map +1 -1
  71. package/dist/db/Db.d.ts +103 -0
  72. package/dist/db/Db.d.ts.map +1 -0
  73. package/dist/db/Db.js +274 -0
  74. package/dist/db/Db.js.map +1 -0
  75. package/dist/db/Schema.d.ts +88 -0
  76. package/dist/db/Schema.d.ts.map +1 -0
  77. package/dist/db/Schema.js +130 -0
  78. package/dist/db/Schema.js.map +1 -0
  79. package/dist/db/tables/organizations.d.ts +79 -0
  80. package/dist/db/tables/organizations.d.ts.map +1 -0
  81. package/dist/db/tables/organizations.js +81 -0
  82. package/dist/db/tables/organizations.js.map +1 -0
  83. package/dist/db/tables/verifiedTokens.d.ts +53 -0
  84. package/dist/db/tables/verifiedTokens.d.ts.map +1 -0
  85. package/dist/db/tables/verifiedTokens.js +74 -0
  86. package/dist/db/tables/verifiedTokens.js.map +1 -0
  87. package/dist/db/tables/webhookDeliveries.d.ts +71 -0
  88. package/dist/db/tables/webhookDeliveries.d.ts.map +1 -0
  89. package/dist/db/tables/webhookDeliveries.js +119 -0
  90. package/dist/db/tables/webhookDeliveries.js.map +1 -0
  91. package/dist/db/tables/webhookSubscriptions.d.ts +158 -0
  92. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -0
  93. package/dist/db/tables/webhookSubscriptions.js +272 -0
  94. package/dist/db/tables/webhookSubscriptions.js.map +1 -0
  95. package/dist/handlers/internal/executionError.d.ts +25 -0
  96. package/dist/handlers/internal/executionError.d.ts.map +1 -0
  97. package/dist/handlers/internal/executionError.js +184 -0
  98. package/dist/handlers/internal/executionError.js.map +1 -0
  99. package/dist/handlers/internal/multisig.d.ts +237 -0
  100. package/dist/handlers/internal/multisig.d.ts.map +1 -0
  101. package/dist/handlers/internal/multisig.js +567 -0
  102. package/dist/handlers/internal/multisig.js.map +1 -0
  103. package/dist/handlers/internal/requestListener.d.ts +124 -0
  104. package/dist/handlers/internal/requestListener.d.ts.map +1 -0
  105. package/dist/handlers/internal/requestListener.js +173 -0
  106. package/dist/handlers/internal/requestListener.js.map +1 -0
  107. package/dist/handlers/internal/sponsorship.d.ts +83 -0
  108. package/dist/handlers/internal/sponsorship.d.ts.map +1 -0
  109. package/dist/handlers/internal/sponsorship.js +103 -0
  110. package/dist/handlers/internal/sponsorship.js.map +1 -0
  111. package/dist/handlers/internal/types.d.ts +284 -0
  112. package/dist/handlers/internal/types.d.ts.map +1 -0
  113. package/dist/handlers/internal/types.js +2 -0
  114. package/dist/handlers/internal/types.js.map +1 -0
  115. package/dist/handlers/internal/utils.d.ts +34 -0
  116. package/dist/handlers/internal/utils.d.ts.map +1 -0
  117. package/dist/handlers/internal/utils.js +131 -0
  118. package/dist/handlers/internal/utils.js.map +1 -0
  119. package/dist/handlers/relay.d.ts +119 -0
  120. package/dist/handlers/relay.d.ts.map +1 -0
  121. package/dist/handlers/relay.js +1264 -0
  122. package/dist/handlers/relay.js.map +1 -0
  123. package/dist/index.d.ts +4 -2
  124. package/dist/index.d.ts.map +1 -1
  125. package/dist/index.js +4 -2
  126. package/dist/index.js.map +1 -1
  127. package/dist/internal/Auth.d.ts +16 -10
  128. package/dist/internal/Auth.d.ts.map +1 -1
  129. package/dist/internal/Auth.js +72 -12
  130. package/dist/internal/Auth.js.map +1 -1
  131. package/dist/internal/RpcSchema.d.ts +10 -0
  132. package/dist/internal/RpcSchema.d.ts.map +1 -0
  133. package/dist/internal/RpcSchema.js +2 -0
  134. package/dist/internal/RpcSchema.js.map +1 -0
  135. package/dist/internal/Store.d.ts +42 -3
  136. package/dist/internal/Store.d.ts.map +1 -1
  137. package/dist/internal/Store.js +65 -3
  138. package/dist/internal/Store.js.map +1 -1
  139. package/dist/internal/VerifiedTokens.d.ts +15 -34
  140. package/dist/internal/VerifiedTokens.d.ts.map +1 -1
  141. package/dist/internal/VerifiedTokens.js +115 -159
  142. package/dist/internal/VerifiedTokens.js.map +1 -1
  143. package/dist/internal/Webhooks.d.ts +49 -35
  144. package/dist/internal/Webhooks.d.ts.map +1 -1
  145. package/dist/internal/Webhooks.js +92 -205
  146. package/dist/internal/Webhooks.js.map +1 -1
  147. package/package.json +7 -2
  148. package/src/ApiKey.test.ts +3 -3
  149. package/src/ApiKey.ts +10 -12
  150. package/src/ApiKeys.test.ts +175 -0
  151. package/src/ApiKeys.ts +205 -0
  152. package/src/App.test-d.ts +1 -1
  153. package/src/App.test.ts +10768 -351
  154. package/src/App.ts +79 -36
  155. package/src/Client.test-d.ts +1 -1
  156. package/src/Handler.ts +154 -0
  157. package/src/Scope.ts +5 -1
  158. package/src/Webhooks.test.ts +8 -7
  159. package/src/Webhooks.ts +11 -5
  160. package/src/admin/Access.ts +1 -3
  161. package/src/admin/App.ts +19 -24
  162. package/src/admin/apps/api-keys.test.ts +19 -18
  163. package/src/admin/apps/api-keys.ts +8 -9
  164. package/src/admin/apps/scopes.test.ts +4 -0
  165. package/src/admin/apps/verified-tokens.test.ts +3 -30
  166. package/src/admin/apps/verified-tokens.ts +17 -36
  167. package/src/admin/ui.generated.ts +1 -1
  168. package/src/apps/balances.test.ts +4 -3
  169. package/src/apps/blocks.test.ts +1 -1
  170. package/src/apps/exchanges.test.ts +1 -1
  171. package/src/apps/indexer.test.ts +1 -1
  172. package/src/apps/receipts.test.ts +1 -1
  173. package/src/apps/rpc.test.ts +100 -10
  174. package/src/apps/tokenlist.test.ts +5 -7
  175. package/src/apps/tokens.test.ts +4 -3
  176. package/src/apps/transactions.test.ts +1 -1
  177. package/src/apps/transfers.test.ts +1 -1
  178. package/src/apps/verified-tokens.test.ts +24 -12
  179. package/src/apps/webhooks.test.ts +130 -143
  180. package/src/apps/webhooks.ts +45 -47
  181. package/src/{Cli.test.ts → cli/Cli.test.ts} +10 -9
  182. package/src/{Cli.ts → cli/Cli.ts} +16 -9
  183. package/src/cli/bin.ts +4 -0
  184. package/src/cli/commands/admin.test.ts +71 -0
  185. package/src/cli/commands/admin.ts +60 -0
  186. package/src/cloudflare.ts +10 -0
  187. package/src/db/Db.test.ts +84 -0
  188. package/src/db/Db.ts +383 -0
  189. package/src/db/Schema.ts +164 -0
  190. package/src/db/tables/organizations.test.ts +124 -0
  191. package/src/db/tables/organizations.ts +127 -0
  192. package/src/db/tables/verifiedTokens.ts +111 -0
  193. package/src/db/tables/webhookDeliveries.ts +159 -0
  194. package/src/db/tables/webhookSubscriptions.ts +358 -0
  195. package/src/handlers/internal/executionError.ts +205 -0
  196. package/src/handlers/internal/multisig.localnet.test.ts +146 -0
  197. package/src/handlers/internal/multisig.test.ts +744 -0
  198. package/src/handlers/internal/multisig.ts +858 -0
  199. package/src/handlers/internal/relay.localnet.test.ts +2035 -0
  200. package/src/handlers/internal/requestListener.ts +273 -0
  201. package/src/handlers/internal/sponsorship.ts +186 -0
  202. package/src/handlers/internal/types.ts +378 -0
  203. package/src/handlers/internal/utils.ts +153 -0
  204. package/src/handlers/relay.ts +1605 -0
  205. package/src/index.ts +4 -2
  206. package/src/internal/Auth.test.ts +21 -21
  207. package/src/internal/Auth.ts +92 -19
  208. package/src/internal/RateLimit.test.ts +1 -1
  209. package/src/internal/RpcSchema.ts +10 -0
  210. package/src/internal/Store.test.ts +98 -10
  211. package/src/internal/Store.ts +112 -6
  212. package/src/internal/VerifiedTokens.test.ts +99 -115
  213. package/src/internal/VerifiedTokens.ts +123 -175
  214. package/src/internal/Webhooks.test.ts +107 -107
  215. package/src/internal/Webhooks.ts +102 -220
  216. package/dist/ApiKeySource.d.ts +0 -166
  217. package/dist/ApiKeySource.d.ts.map +0 -1
  218. package/dist/ApiKeySource.js +0 -234
  219. package/dist/ApiKeySource.js.map +0 -1
  220. package/dist/Cli.d.ts.map +0 -1
  221. package/dist/Cli.js +0 -13
  222. package/dist/Cli.js.map +0 -1
  223. package/seeds/31318/icons/0x20c0000000000000000000000000000000000000.svg +0 -1
  224. package/seeds/31318/icons/0x20c0000000000000000000000000000000000001.svg +0 -1
  225. package/seeds/31318/icons/0x20c0000000000000000000000000000000000002.svg +0 -1
  226. package/seeds/31318/icons/0x20c0000000000000000000000000000000000003.svg +0 -1
  227. package/seeds/31318/verified-tokens.json +0 -30
  228. package/seeds/4217/icons/0x20c0000000000000000000000000000000000000.svg +0 -25
  229. package/seeds/4217/icons/0x20c000000000000000000000048c8f36df1c9a4a.svg +0 -1
  230. package/seeds/4217/icons/0x20c0000000000000000000000520792dcccccccc.svg +0 -1
  231. package/seeds/4217/icons/0x20c0000000000000000000000a6da882d075a4c3.svg +0 -4
  232. package/seeds/4217/icons/0x20c000000000000000000000111111111e910f0f.svg +0 -30
  233. package/seeds/4217/icons/0x20c00000000000000000000014f22ca97301eb73.svg +0 -1
  234. package/seeds/4217/icons/0x20c0000000000000000000001621e21f71cf12fb.svg +0 -1
  235. package/seeds/4217/icons/0x20c0000000000000000000002f52d5cc21a3207b.svg +0 -1
  236. package/seeds/4217/icons/0x20c0000000000000000000003158081efd85bfc2.svg +0 -11
  237. package/seeds/4217/icons/0x20c0000000000000000000003554d28269e0f3c2.svg +0 -1
  238. package/seeds/4217/icons/0x20c000000000000000000000383a23bacb546ab9.svg +0 -1
  239. package/seeds/4217/icons/0x20c00000000000000000000042109aef2f8b28e1.png +0 -0
  240. package/seeds/4217/icons/0x20c0000000000000000000005c0bac7cef389a11.svg +0 -1
  241. package/seeds/4217/icons/0x20c0000000000000000000006fd9a167923ba194.png +0 -0
  242. package/seeds/4217/icons/0x20c0000000000000000000007f7ba549dd0251b9.svg +0 -1
  243. package/seeds/4217/icons/0x20c0000000000000000000008191667423f70e67.svg +0 -5
  244. package/seeds/4217/icons/0x20c0000000000000000000008ee4fcff88888888.svg +0 -1
  245. package/seeds/4217/icons/0x20c0000000000000000000009a4a4b17e0dc6651.svg +0 -1
  246. package/seeds/4217/icons/0x20c000000000000000000000ab02d39df30bd17e.svg +0 -1
  247. package/seeds/4217/icons/0x20c000000000000000000000ae247a1130450f09.svg +0 -1
  248. package/seeds/4217/icons/0x20c000000000000000000000aeed2ec36a54d0e5.svg +0 -1
  249. package/seeds/4217/icons/0x20c000000000000000000000b9537d11c60e8b50.svg +0 -1
  250. package/seeds/4217/icons/0x20c000000000000000000000bd95bfb69fbe6ce3.svg +0 -1
  251. package/seeds/4217/icons/0x20c000000000000000000000c412ec89d0c08be5.svg +0 -1
  252. package/seeds/4217/icons/0x20c000000000000000000000f047dd7018e50367.png +0 -0
  253. package/seeds/4217/verified-tokens.json +0 -177
  254. package/seeds/42431/icons/0x20c0000000000000000000000000000000000000.svg +0 -25
  255. package/seeds/42431/icons/0x20c0000000000000000000000000000000000001.svg +0 -1
  256. package/seeds/42431/icons/0x20c0000000000000000000000000000000000002.svg +0 -1
  257. package/seeds/42431/icons/0x20c0000000000000000000000000000000000003.svg +0 -1
  258. package/seeds/42431/icons/0x20c0000000000000000000009e8d7eb59b783726.svg +0 -1
  259. package/seeds/42431/icons/0x20c000000000000000000000d72572838bbee59c.svg +0 -1
  260. package/seeds/42431/verified-tokens.json +0 -44
  261. package/seeds/openrpc/README.md +0 -27
  262. package/seeds/openrpc/execution-apis.json +0 -8989
  263. package/src/ApiKeySource.test-d.ts +0 -11
  264. package/src/ApiKeySource.test.ts +0 -295
  265. package/src/ApiKeySource.ts +0 -303
@@ -0,0 +1,1605 @@
1
+ import { AbiEvent, Hex, RpcRequest, RpcResponse } from 'ox'
2
+ import { Transaction as core_Transaction } from 'ox/tempo'
3
+ import {
4
+ type Address,
5
+ type Call,
6
+ type Client,
7
+ createClient,
8
+ decodeFunctionData,
9
+ formatUnits,
10
+ http,
11
+ isAddress,
12
+ type Log,
13
+ parseEventLogs,
14
+ zeroAddress,
15
+ } from 'viem'
16
+ import type { LocalAccount } from 'viem/accounts'
17
+ import { simulateCalls } from 'viem/actions'
18
+ import { Abis, Actions, Addresses, Capabilities, Transaction, VirtualAddress } from 'viem/tempo'
19
+
20
+ import * as ExecutionError from './internal/executionError.js'
21
+ import type * as RpcSchema from '../internal/RpcSchema.js'
22
+ import { type Handler, from } from '../Handler.js'
23
+ import * as Store from '../internal/Store.js'
24
+ import * as Multisig from './internal/multisig.js'
25
+ import * as Sponsorship from './internal/sponsorship.js'
26
+ import * as Utils from './internal/utils.js'
27
+
28
+ /**
29
+ * Instantiates a relay handler that proxies `eth_fillTransaction`
30
+ * with wallet-aware enrichment (fee token resolution, simulation,
31
+ * sponsorship, AMM resolution).
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * import { Handler } from 'tempo-api/server'
36
+ *
37
+ * const handler = Handler.relay({ getClient })
38
+ *
39
+ * // Plug handler into your server framework of choice:
40
+ * createServer(handler.listener) // Node.js
41
+ * Bun.serve(handler) // Bun
42
+ * Deno.serve(handler) // Deno
43
+ * app.use(handler.listener) // Express
44
+ * app.all('*', c => handler.fetch(c.req.raw)) // Hono
45
+ * export const GET = handler.fetch // Next.js
46
+ * export const POST = handler.fetch // Next.js
47
+ * ```
48
+ *
49
+ * @example
50
+ * With sponsorship
51
+ *
52
+ * ```ts
53
+ * import { privateKeyToAccount } from 'viem/accounts'
54
+ * import { Handler } from 'tempo-api/server'
55
+ *
56
+ * const handler = Handler.relay({
57
+ * getClient,
58
+ * feePayer: {
59
+ * account: privateKeyToAccount('0x...'),
60
+ * },
61
+ * })
62
+ * ```
63
+ *
64
+ * @param options - Options.
65
+ * @returns Request handler.
66
+ */
67
+ export function relay(options: relay.Options): Handler {
68
+ const {
69
+ getClient,
70
+ internal_allowUnsafeUrls = false,
71
+ onRequest,
72
+ path = '/',
73
+ resolveTokens,
74
+ ...rest
75
+ } = options
76
+ const feePayerOptions = options.feePayer
77
+ // Adapt the state store into the multisig operation store once, up front.
78
+ const multisigOptions = options.multisig
79
+ ? { ...options.multisig, store: Multisig.fromStore(options.multisig.store) }
80
+ : undefined
81
+
82
+ // Resolves the chain's fee-token candidates through the caller-provided
83
+ // resolver. An unconfigured resolver (or a chain it has no list for) yields
84
+ // an empty candidate set.
85
+ const getTokens = async (chainId: number | undefined): Promise<readonly Address[]> => {
86
+ if (!resolveTokens || chainId === undefined) return []
87
+ return await resolveTokens(chainId)
88
+ }
89
+
90
+ // Memoize cache for TIP-20 metadata and user fee-token preferences. Kept
91
+ // opt-in: an always-on cache would pin a raced null preference (set, then
92
+ // fill before the set mines) for the whole TTL.
93
+ const store = options.cache
94
+
95
+ const features = {
96
+ autoSwap: options.autoSwap ?? options.features === 'all',
97
+ feeTokenResolution: options.features === 'all' || Boolean(resolveTokens),
98
+ simulate: options.features === 'all',
99
+ }
100
+
101
+ const autoSwap = (() => {
102
+ if (!features.autoSwap) return undefined
103
+ if (options.autoSwap === false) return undefined
104
+ return {
105
+ slippage:
106
+ (typeof options.autoSwap === 'object' ? options.autoSwap?.slippage : undefined) ?? 0.05,
107
+ }
108
+ })()
109
+
110
+ async function handleRequest(
111
+ request: RpcRequest.RpcRequest<RpcSchema.Ox>,
112
+ options?: { chainId?: number | undefined },
113
+ ) {
114
+ await onRequest?.(request)
115
+
116
+ // Resolve chainId from: 1) explicit option (URL path), 2) first param object, 3) default chain.
117
+ const params = 'params' in request && Array.isArray(request.params) ? request.params : []
118
+ const first =
119
+ typeof params[0] === 'object' && params[0]
120
+ ? (params[0] as Record<string, unknown>)
121
+ : undefined
122
+ const chainId =
123
+ Utils.resolveChainId(first?.['chainId']) ?? options?.chainId ?? getClient().chain?.id
124
+ const client = getClient(chainId)
125
+
126
+ switch (request.method) {
127
+ case 'eth_fillTransaction': {
128
+ const parameters = params[0] as Record<string, unknown>
129
+ const capabilities = (parameters['capabilities'] ?? {}) as Record<string, unknown>
130
+
131
+ try {
132
+ const from =
133
+ typeof parameters['from'] === 'string' ? (parameters['from'] as Address) : undefined
134
+ const requestFeeToken =
135
+ typeof parameters['feeToken'] === 'string'
136
+ ? (parameters['feeToken'] as Address)
137
+ : undefined
138
+ const externalFeePayerUrl =
139
+ typeof parameters['feePayer'] === 'string'
140
+ ? normalizeExternalFeePayerUrl(parameters['feePayer'], {
141
+ allowUnsafe: internal_allowUnsafeUrls,
142
+ })
143
+ : undefined
144
+ const requestsSponsorship =
145
+ (!!feePayerOptions || !!externalFeePayerUrl) && parameters['feePayer'] !== false
146
+ // Default to `true`. Dapps that don't render diffs can pass
147
+ // `capabilities.balanceDiffs: false` to skip the post-fill
148
+ // `tempo_simulateV1` round trip (~250-400ms).
149
+ const requireBalanceDiffs = capabilities['balanceDiffs'] !== false
150
+
151
+ const { feePayer: _feePayer, ...normalized } =
152
+ Utils.normalizeFillTransactionRequest(parameters)
153
+
154
+ // When sponsoring, the sponsor's preferred fee token (if set)
155
+ // overrides whatever the consumer asked for — the sponsor is
156
+ // paying, so the sponsor picks. Falls back to the request's
157
+ // feeToken otherwise.
158
+ const sponsoredFeeToken = requestsSponsorship
159
+ ? (feePayerOptions?.feeToken ?? requestFeeToken)
160
+ : requestFeeToken
161
+
162
+ const baseTx = {
163
+ ...normalized,
164
+ ...(typeof chainId !== 'undefined' ? { chainId } : {}),
165
+ ...(sponsoredFeeToken ? { feeToken: sponsoredFeeToken } : {}),
166
+ }
167
+
168
+ let filled: Awaited<ReturnType<typeof fill>>
169
+ let sponsored = false
170
+ let feeToken = sponsoredFeeToken
171
+
172
+ // Lazily resolve a swap source token when autoSwap needs one.
173
+ const resolveFeeTokenForSwap = from
174
+ ? async (insufficientToken: Address) =>
175
+ resolveFeeToken(client, {
176
+ account: from,
177
+ feeToken: undefined,
178
+ store,
179
+ tokens: (await getTokens(chainId)).filter(
180
+ (t) => t.toLowerCase() !== insufficientToken.toLowerCase(),
181
+ ),
182
+ })
183
+ : undefined
184
+
185
+ // When no sponsor will pay, prefer fee tokens the user actually
186
+ // holds. Extend the configured token list with any TIP20 token
187
+ // the transaction is calling — typically the token being
188
+ // transferred — so a user transferring USDC.e can pay gas in
189
+ // USDC.e even when the configured list defaults to pathUSD.
190
+ const configuredTokens = await getTokens(chainId)
191
+ const unsponsoredTokens = [
192
+ ...configuredTokens,
193
+ ...callTargetTokens(baseTx).filter(
194
+ (t) => !configuredTokens.some((rt) => rt.toLowerCase() === t.toLowerCase()),
195
+ ),
196
+ ]
197
+
198
+ // When the app provides its own fee payer URL, route the fill
199
+ // through that service so it can sign the transaction.
200
+ const fillClient = externalFeePayerUrl
201
+ ? createClient({
202
+ chain: client.chain,
203
+ batch: { multicall: { deployless: true } },
204
+ transport: http(externalFeePayerUrl),
205
+ })
206
+ : client
207
+
208
+ if (requestsSponsorship && (externalFeePayerUrl || !feePayerOptions?.validate)) {
209
+ // Path A: sponsorship guaranteed — skip fee token resolution,
210
+ // fill once with feePayer, then parallelize the rest. Taken when:
211
+ // - no validate is configured (no gating), OR
212
+ // - the app supplied its own external fee payer URL (that
213
+ // relay is the sponsorship authority — the wallet's own
214
+ // `validate` only governs the wallet's own fee payer).
215
+ // Default to the chain's first token so the broadcast envelope
216
+ // always carries a feeToken the chain can charge.
217
+ if (!feeToken) feeToken = (await getTokens(chainId))[0]
218
+ const transaction = {
219
+ ...baseTx,
220
+ feePayer: true,
221
+ ...(feeToken ? { feeToken } : {}),
222
+ }
223
+ if (Sponsorship.isPreparedTransaction(transaction)) {
224
+ filled = {
225
+ transaction: Utils.normalizeTempoTransaction(transaction),
226
+ sponsor: undefined,
227
+ }
228
+ } else {
229
+ filled = await fill(fillClient, {
230
+ autoSwap,
231
+ feeToken,
232
+ store,
233
+ resolveFeeToken: resolveFeeTokenForSwap,
234
+ transaction,
235
+ })
236
+ // The chain echoes feeToken: null for sponsored fills served
237
+ // by viem clients that strip feeToken pre-sign; re-inject
238
+ // before the spread in mergeCallsFromRequest drops it.
239
+ if (
240
+ feeToken &&
241
+ filled?.transaction &&
242
+ (filled.transaction as { feeToken?: Address | null }).feeToken == null
243
+ )
244
+ (filled.transaction as { feeToken?: Address }).feeToken = feeToken
245
+ }
246
+ sponsored = true
247
+ } else if (requestsSponsorship && feePayerOptions?.validate) {
248
+ // Path B: sponsorship possible but may be rejected.
249
+ // Speculatively fill the sponsored variant, validate, and only
250
+ // fall back to the unsponsored path on rejection. The accept
251
+ // case (common) avoids the upfront `resolveFeeToken` round
252
+ // trips and the second `eth_fillTransaction`.
253
+ const sponsoredTx = { ...baseTx, feePayer: true }
254
+
255
+ if (Sponsorship.isPreparedTransaction(sponsoredTx)) {
256
+ // Already prepared — skip fills, just validate sponsorship.
257
+ const prepared = {
258
+ transaction: Utils.normalizeTempoTransaction(sponsoredTx),
259
+ sponsor: undefined,
260
+ }
261
+ sponsored = await Sponsorship.shouldSponsor({
262
+ sender: from,
263
+ transaction: prepared.transaction,
264
+ validate: feePayerOptions!.validate,
265
+ })
266
+ filled = prepared
267
+ } else {
268
+ const options = {
269
+ autoSwap,
270
+ store,
271
+ resolveFeeToken: resolveFeeTokenForSwap,
272
+ }
273
+ const fill_sponsored = await fill(fillClient, {
274
+ ...options,
275
+ feeToken,
276
+ transaction: sponsoredTx,
277
+ })
278
+ sponsored = await Sponsorship.shouldSponsor({
279
+ sender: from,
280
+ transaction: fill_sponsored.transaction,
281
+ validate: feePayerOptions!.validate,
282
+ })
283
+ if (sponsored) {
284
+ filled = fill_sponsored
285
+ } else {
286
+ // Sponsor rejected — resolve fee token and fill unsponsored.
287
+ const feeToken_unsponsored = features.feeTokenResolution
288
+ ? await resolveFeeToken(client, {
289
+ account: from,
290
+ feeToken: requestFeeToken,
291
+ store,
292
+ tokens: unsponsoredTokens,
293
+ })
294
+ : requestFeeToken
295
+ const tx_unsponsored = {
296
+ ...baseTx,
297
+ ...(feeToken_unsponsored ? { feeToken: feeToken_unsponsored } : {}),
298
+ }
299
+ filled = await fill(client, {
300
+ ...options,
301
+ feeToken: feeToken_unsponsored,
302
+ transaction: tx_unsponsored,
303
+ })
304
+ feeToken = feeToken_unsponsored
305
+ }
306
+ }
307
+ } else {
308
+ // Path C: no sponsorship configured — resolve fee token, fill once.
309
+ feeToken = features.feeTokenResolution
310
+ ? await resolveFeeToken(client, {
311
+ account: from,
312
+ feeToken: requestFeeToken,
313
+ store,
314
+ tokens: unsponsoredTokens,
315
+ })
316
+ : requestFeeToken
317
+ const transaction = { ...baseTx, ...(feeToken ? { feeToken } : {}) }
318
+ filled = await fill(client, {
319
+ autoSwap,
320
+ feeToken,
321
+ store,
322
+ resolveFeeToken: resolveFeeTokenForSwap,
323
+ transaction,
324
+ })
325
+ }
326
+
327
+ const transaction_filled = filled.transaction
328
+ const swap = 'swap' in filled ? filled.swap : undefined
329
+ if (!feeToken)
330
+ feeToken =
331
+ (transaction_filled.feeToken as Address | undefined) ?? (await getTokens(chainId))[0]
332
+
333
+ // Parallelize: simulate, fee payer signing, and autoSwap metadata.
334
+ const alreadySigned =
335
+ 'feePayerSignature' in transaction_filled &&
336
+ transaction_filled.feePayerSignature != null
337
+
338
+ const calls = extractCalls(transaction_filled)
339
+ const [simulation, transaction_final, autoSwap_, virtualAddresses] = await Promise.all([
340
+ // Simulate and compute balance diffs + fee.
341
+ // When `capabilities.balanceDiffs` is `false`, skip simulate
342
+ // and compute fee directly from cached metadata.
343
+ (async () => {
344
+ if (!features.simulate) return { balanceDiffs: undefined, fee: undefined }
345
+ if (requireBalanceDiffs)
346
+ return simulateAndParseDiffs(client, {
347
+ account: from,
348
+ calls,
349
+ swap,
350
+ feeToken,
351
+ gas: transaction_filled.gas,
352
+ store,
353
+ maxFeePerGas: transaction_filled.maxFeePerGas,
354
+ })
355
+ const fee = await computeFee(client, {
356
+ feeToken,
357
+ gas: transaction_filled.gas,
358
+ store,
359
+ maxFeePerGas: transaction_filled.maxFeePerGas,
360
+ }).catch(() => undefined)
361
+ return { balanceDiffs: undefined, fee }
362
+ })(),
363
+ // Sign as fee payer (if sponsored and not already signed).
364
+ (async () => {
365
+ if (!(sponsored && feePayerOptions && !alreadySigned)) return transaction_filled
366
+ return Sponsorship.sign({
367
+ account: feePayerOptions.account,
368
+ sender: from,
369
+ transaction: transaction_filled,
370
+ })
371
+ })(),
372
+ // Resolve autoSwap metadata (when AMM path was taken).
373
+ resolveAutoSwapMetadata(client, { autoSwap, store, swap }),
374
+ // Resolve virtual-address recipients so wallets can show the
375
+ // eventual master address before signing.
376
+ resolveVirtualAddresses(client, { calls }),
377
+ ])
378
+ const { balanceDiffs, fee } = simulation
379
+
380
+ const sponsor = (() => {
381
+ if (!sponsored) return undefined
382
+ // App-provided fee payer: relay back the sponsor from the upstream response.
383
+ if (externalFeePayerUrl) return filled.sponsor
384
+ if (feePayerOptions) return Sponsorship.getSponsor(feePayerOptions)
385
+ return filled.sponsor
386
+ })()
387
+
388
+ return RpcResponse.from(
389
+ {
390
+ result: {
391
+ ...(sponsor ? { sponsor } : {}),
392
+ tx: core_Transaction.toRpc(transaction_final as core_Transaction.Transaction),
393
+ capabilities: {
394
+ balanceDiffs,
395
+ fee,
396
+ sponsored: !!sponsor,
397
+ ...(sponsor ? { sponsor } : {}),
398
+ ...(autoSwap_ ? { autoSwap: autoSwap_ } : {}),
399
+ ...(virtualAddresses ? { virtualAddresses } : {}),
400
+ },
401
+ },
402
+ },
403
+ { request },
404
+ )
405
+ } catch (error) {
406
+ if (!(error instanceof Error)) return Utils.rpcErrorJson(request, error)
407
+ if (capabilities['errors'] !== true) return Utils.rpcErrorJson(request, error)
408
+
409
+ const revert = ExecutionError.parse(error)
410
+
411
+ const parameters = request.params?.[0] as Record<string, unknown>
412
+ const stub = {
413
+ from: parameters['from'],
414
+ to: parameters['to'] ?? null,
415
+ gas: '0x0',
416
+ nonce: '0x0',
417
+ value: '0x0',
418
+ maxFeePerGas: '0x0',
419
+ maxPriorityFeePerGas: '0x0',
420
+ }
421
+
422
+ if (revert?.errorName === 'InsufficientBalance') {
423
+ const args = revert.args as [bigint, bigint, Address]
424
+ const [available, required, token] = args
425
+
426
+ const normalized = Utils.normalizeFillTransactionRequest(parameters)
427
+
428
+ // Simulate from zero address for optimistic balance diffs.
429
+ const optimisticCalls = normalized ? extractCalls(normalized) : undefined
430
+ const [{ balanceDiffs }, virtualAddresses] = optimisticCalls
431
+ ? await Promise.all([
432
+ simulateAndParseDiffs(client, {
433
+ account: zeroAddress,
434
+ calls: optimisticCalls,
435
+ store,
436
+ }),
437
+ resolveVirtualAddresses(client, { calls: optimisticCalls }),
438
+ ])
439
+ : [{ balanceDiffs: undefined }, undefined]
440
+
441
+ // Re-key balance diffs from zero address to the real sender.
442
+ const senderDiffs =
443
+ parameters['from'] && balanceDiffs
444
+ ? { [parameters['from'] as Address]: balanceDiffs[zeroAddress] ?? [] }
445
+ : balanceDiffs
446
+
447
+ const metadata = await resolveTokenMetadata(client, { token, store }).catch(
448
+ () => undefined,
449
+ )
450
+ const deficit = required - available
451
+ return RpcResponse.from(
452
+ {
453
+ result: {
454
+ tx: stub,
455
+ capabilities: {
456
+ balanceDiffs: senderDiffs,
457
+ error: ExecutionError.serialize(revert),
458
+ requireFunds: metadata
459
+ ? {
460
+ amount: Hex.fromNumber(deficit) as `0x${string}`,
461
+ decimals: metadata.decimals,
462
+ formatted: formatUnits(deficit, metadata.decimals),
463
+ token,
464
+ symbol: metadata.symbol,
465
+ }
466
+ : undefined,
467
+ sponsored: false,
468
+ ...(virtualAddresses ? { virtualAddresses } : {}),
469
+ },
470
+ },
471
+ },
472
+ { request },
473
+ )
474
+ }
475
+
476
+ const normalized = Utils.normalizeFillTransactionRequest(parameters)
477
+ const virtualAddresses = normalized
478
+ ? await resolveVirtualAddresses(client, { calls: extractCalls(normalized) }).catch(
479
+ () => undefined,
480
+ )
481
+ : undefined
482
+
483
+ return RpcResponse.from(
484
+ {
485
+ result: {
486
+ tx: stub,
487
+ capabilities: {
488
+ error: ExecutionError.serialize(revert),
489
+ sponsored: false,
490
+ ...(virtualAddresses ? { virtualAddresses } : {}),
491
+ },
492
+ },
493
+ },
494
+ { request },
495
+ )
496
+ }
497
+ }
498
+
499
+ case 'eth_signRawTransaction':
500
+ case 'eth_sendRawTransaction':
501
+ case 'eth_sendRawTransactionSync': {
502
+ try {
503
+ const serialized = params[0]
504
+ if (
505
+ multisigOptions &&
506
+ (request.method === 'eth_sendRawTransaction' ||
507
+ request.method === 'eth_sendRawTransactionSync') &&
508
+ typeof serialized === 'string' &&
509
+ Multisig.isMultisigTransaction(serialized as Hex.Hex)
510
+ ) {
511
+ const result = await Multisig.handleRawTransaction({
512
+ claimTtl: multisigOptions.claimTtl,
513
+ finalize: multisigOptions.finalize,
514
+ getClient,
515
+ method: request.method,
516
+ request: { params: 'params' in request ? request.params : undefined },
517
+ resolveConfig: multisigOptions.resolveConfig,
518
+ sponsor: feePayerOptions
519
+ ? {
520
+ account: feePayerOptions.account,
521
+ feeToken: feePayerOptions.feeToken,
522
+ resolveFeeToken: async (chainId) => (await getTokens(chainId))[0],
523
+ validate: feePayerOptions.validate,
524
+ }
525
+ : undefined,
526
+ store: multisigOptions.store,
527
+ })
528
+ return RpcResponse.from({ result } as never, { request } as never)
529
+ }
530
+
531
+ if (!feePayerOptions) {
532
+ // eth_signRawTransaction is a signing method that only the
533
+ // relay can fulfill — forwarding it to the RPC node returns
534
+ // an opaque "Method not found". Return an actionable error.
535
+ if ((request as { method: string }).method === 'eth_signRawTransaction') {
536
+ return RpcResponse.from(
537
+ {
538
+ error: {
539
+ code: -32601,
540
+ message:
541
+ 'eth_signRawTransaction requires a fee payer to be configured on the relay. ' +
542
+ 'Set the `feePayer` option in `Handler.relay()` to enable transaction sponsorship.',
543
+ },
544
+ } as never,
545
+ { request } as never,
546
+ )
547
+ }
548
+ const result = await client.request(request as never)
549
+ return RpcResponse.from({ result }, { request })
550
+ }
551
+
552
+ if (
553
+ typeof serialized !== 'string' ||
554
+ !Sponsorship.requestsRawSponsorship(serialized as `0x${string}`)
555
+ ) {
556
+ const result = await client.request(request as never)
557
+ return RpcResponse.from({ result }, { request })
558
+ }
559
+
560
+ const result = await Sponsorship.handleRawTransaction({
561
+ account: feePayerOptions.account,
562
+ feeToken: feePayerOptions.feeToken,
563
+ getFeeToken: async (chainId) => (await getTokens(chainId))[0],
564
+ getClient,
565
+ method: request.method as Sponsorship.handleRawTransaction.Options['method'],
566
+ request: { params: 'params' in request ? request.params : undefined },
567
+ validate: feePayerOptions.validate,
568
+ })
569
+ return RpcResponse.from({ result } as never, { request } as never)
570
+ } catch (error) {
571
+ return Utils.rpcErrorJson(request, error)
572
+ }
573
+ }
574
+
575
+ case 'eth_getTransactionByHash':
576
+ case 'eth_getTransactionReceipt': {
577
+ try {
578
+ if (multisigOptions) {
579
+ const aliased = await Multisig.handleGetTransaction({
580
+ getClient,
581
+ method: request.method,
582
+ request: { params: 'params' in request ? request.params : undefined },
583
+ store: multisigOptions.store,
584
+ })
585
+ if (aliased)
586
+ return RpcResponse.from({ result: aliased.result } as never, { request } as never)
587
+ }
588
+
589
+ const result = await client.request(request as never)
590
+ return RpcResponse.from({ result }, { request })
591
+ } catch (error) {
592
+ return Utils.rpcErrorJson(request, error)
593
+ }
594
+ }
595
+
596
+ default: {
597
+ try {
598
+ const result = await client.request(request as never)
599
+ return RpcResponse.from({ result }, { request })
600
+ } catch (error) {
601
+ return Utils.rpcErrorJson(request, error)
602
+ }
603
+ }
604
+ }
605
+ }
606
+
607
+ const router = from(rest)
608
+
609
+ async function handlePost(c: { req: { raw: Request; param: (key: string) => string } }) {
610
+ const chainId = Utils.resolveChainId(c.req.param('chainId'))
611
+ const body = await c.req.raw.json()
612
+ const isBatch = Array.isArray(body)
613
+
614
+ if (!isBatch) {
615
+ const request = RpcRequest.from(body as never) as RpcRequest.RpcRequest<RpcSchema.Ox>
616
+ return Response.json(await handleRequest(request, { chainId }))
617
+ }
618
+
619
+ const responses = await Promise.all(
620
+ (body as unknown[]).map((item) =>
621
+ handleRequest(RpcRequest.from(item as never) as RpcRequest.RpcRequest<RpcSchema.Ox>, {
622
+ chainId,
623
+ }),
624
+ ),
625
+ )
626
+ return Response.json(responses)
627
+ }
628
+
629
+ router.post(path, handlePost as never)
630
+ router.post(`${path === '/' ? '' : path}/:chainId`, handlePost as never)
631
+
632
+ return router
633
+ }
634
+
635
+ export namespace relay {
636
+ export type Options = from.Options & {
637
+ /**
638
+ * Auto-swap options.
639
+ */
640
+ autoSwap?:
641
+ | false
642
+ | {
643
+ /** Slippage tolerance (e.g. 0.05 = 5%). @default 0.05 */
644
+ slippage?: number | undefined
645
+ }
646
+ | undefined
647
+ /**
648
+ * Resolves a viem client for a chain id. The handler resolves the client
649
+ * from the `chainId` in the incoming transaction (falling back to the
650
+ * client's default chain). Called with no argument for the default client.
651
+ */
652
+ getClient: (chainId?: number) => Client
653
+ /**
654
+ * Fee payer / sponsor configuration. When provided, the relay will
655
+ * sign `feePayerSignature` on the filled transaction.
656
+ */
657
+ feePayer?:
658
+ | {
659
+ /** Account to use as the fee payer. */
660
+ account: LocalAccount
661
+ /** Preferred fee token the sponsor wants to pay fees in. */
662
+ feeToken?: Address | undefined
663
+ /**
664
+ * Validates whether to sponsor the transaction. When omitted, all
665
+ * transactions are sponsored. Return `false` to reject sponsorship.
666
+ */
667
+ validate?:
668
+ | ((request: Transaction.TransactionRequest) => boolean | Promise<boolean>)
669
+ | undefined
670
+ /** Sponsor display name returned from `eth_fillTransaction`. */
671
+ name?: string | undefined
672
+ /** Sponsor URL returned from `eth_fillTransaction`. */
673
+ url?: string | undefined
674
+ }
675
+ | undefined
676
+ /**
677
+ * Native multisig configuration. When provided, the relay collects owner
678
+ * approvals from multisig raw transaction submissions and broadcasts once
679
+ * collected owner weight meets the configured threshold.
680
+ */
681
+ multisig?: Multisig.Options | undefined
682
+ /**
683
+ * Relay features.
684
+ *
685
+ * - `'all'` — enables fee token resolution, auto-swap,
686
+ * fee payer, and simulation (balance diffs + fee breakdown).
687
+ * - `undefined` (default) — only fee payers.
688
+ */
689
+ features?: 'all' | undefined
690
+ /**
691
+ * Allows app-provided external fee payer URLs to use non-HTTPS protocols
692
+ * or local/private hosts. Intended only for trusted development setups.
693
+ * @default false
694
+ */
695
+ internal_allowUnsafeUrls?: boolean | undefined
696
+ /**
697
+ * In-process cache backing TIP-20 metadata and user fee-token preference
698
+ * memoization. Omit to read upstream on every fill.
699
+ */
700
+ cache?: Store.Store | undefined
701
+ /** Function to call before handling the request. */
702
+ onRequest?: ((request: RpcRequest.RpcRequest) => Promise<void>) | undefined
703
+ /** Path to use for the handler. @default "/" */
704
+ path?: string | undefined
705
+ /**
706
+ * Resolves the known fee-token candidates for a chain. The relay checks
707
+ * the caller's balance for each candidate and picks the highest during
708
+ * fee-token resolution. Omit to disable fee-token resolution.
709
+ */
710
+ resolveTokens?:
711
+ | ((chainId: number) => readonly Address[] | Promise<readonly Address[]>)
712
+ | undefined
713
+ }
714
+ }
715
+
716
+ // TODO: cleanup
717
+ async function fill(client: Client, options: fill.Options) {
718
+ const { autoSwap, feeToken, store, transaction: request } = options
719
+
720
+ // Skip re-formatting if already in RPC format (e.g. from viem's fillTransaction).
721
+ const format = (value: Record<string, unknown>) =>
722
+ value['type'] === '0x76' ? value : Utils.formatFillTransactionRequest(client, value)
723
+
724
+ // Re-fill the transaction with prepended swap calls so the user can
725
+ // mint the missing `insufficientToken` (typically the fee token) from
726
+ // a token they already hold. Returns null if no source token is
727
+ // available or autoSwap is disabled.
728
+ async function fillWithSwap(insufficientToken: Address, deficit: bigint) {
729
+ if (!autoSwap) return null
730
+ const sourceToken =
731
+ feeToken && feeToken.toLowerCase() !== insufficientToken.toLowerCase()
732
+ ? feeToken
733
+ : await options.resolveFeeToken?.(insufficientToken)
734
+ if (!sourceToken || sourceToken.toLowerCase() === insufficientToken.toLowerCase()) return null
735
+
736
+ const maxAmountIn = deficit + (deficit * BigInt(Math.round(autoSwap.slippage * 1000))) / 1000n
737
+ const originalCalls = (request['calls'] as Call[] | undefined) ?? []
738
+ const swapCalls = buildSwapCalls(client, sourceToken, insufficientToken, deficit, maxAmountIn)
739
+
740
+ const result = await client.request({
741
+ method: 'eth_fillTransaction',
742
+ params: [
743
+ format({
744
+ ...request,
745
+ calls: [...swapCalls, ...originalCalls],
746
+ }) as never,
747
+ ],
748
+ })
749
+ const sponsor = (result as Record<string, any>)['capabilities']?.sponsor as
750
+ | { address: Address; name?: string; url?: string }
751
+ | undefined
752
+ const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, {
753
+ ...request,
754
+ calls: [...swapCalls, ...originalCalls],
755
+ })
756
+ return {
757
+ transaction: Utils.normalizeTempoTransaction(mergedTx),
758
+ sponsor,
759
+ swap: {
760
+ calls: swapCalls,
761
+ tokenIn: sourceToken,
762
+ tokenOut: insufficientToken,
763
+ amountOut: deficit,
764
+ maxAmountIn,
765
+ },
766
+ }
767
+ }
768
+
769
+ try {
770
+ const formatted = format(request)
771
+ const result = await client.request({
772
+ method: 'eth_fillTransaction',
773
+ params: [formatted as never],
774
+ })
775
+ // FIXME: node estimates gas with secp256k1 dummy sig + null feePayerSignature.
776
+ // Actual tx has larger keychain/webAuthn sigs + real fee payer sig, costing
777
+ // more intrinsic gas. Mirror the bump from viem's tempo chainConfig.
778
+ // Skip if another relay already bumped (indicated by feePayerSignature).
779
+ // @ts-expect-error
780
+ if (result.tx.gas && request.feePayer && !result.tx.feePayerSignature)
781
+ result.tx.gas = Hex.fromNumber(BigInt(result.tx.gas) + 20_000n)
782
+ const upstreamCapabilities = (result as { capabilities?: Record<string, unknown> }).capabilities
783
+ const sponsor = upstreamCapabilities?.['sponsor'] as
784
+ | { address: Address; name?: string; url?: string }
785
+ | undefined
786
+ // External fee-payer relays surface chain reverts (e.g. InsufficientBalance,
787
+ // InsufficientAllowance) inside `capabilities.error` with a stub `tx`
788
+ // instead of throwing. Re-throw all upstream errors so the outer handler
789
+ // either recovers via autoSwap (InsufficientBalance) or propagates the
790
+ // error to the caller. Without this, the wallet would otherwise sign the
791
+ // zero-stub tx with its own fee payer and broadcast garbage.
792
+ const upstreamError = upstreamCapabilities?.['error'] as
793
+ | { errorName?: string; message?: string; data?: `0x${string}` }
794
+ | undefined
795
+ if (upstreamError) {
796
+ const synthetic = new Error(
797
+ upstreamError.message ?? upstreamError.errorName ?? 'UpstreamRevert',
798
+ )
799
+ synthetic.name = 'UpstreamRevertError'
800
+ ;(synthetic as { data?: `0x${string}` | undefined }).data = upstreamError.data
801
+ throw synthetic
802
+ }
803
+ // Reconstruct a `swap` shape from upstream's `capabilities.autoSwap` so the
804
+ // wallet relay's outer code can re-resolve autoSwap metadata locally —
805
+ // otherwise upstream-driven swaps are silently dropped from the response.
806
+ const swap = extractSwapFromCapabilities(upstreamCapabilities?.['autoSwap'])
807
+ // The chain's `eth_fillTransaction` doesn't echo back `calls`, so merge
808
+ // them in from the original request before normalizing — otherwise the
809
+ // typed envelope built for sponsorship signing throws CallsEmptyError.
810
+ const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, request)
811
+
812
+ // The node's `eth_fillTransaction` may pick a fee token the user
813
+ // doesn't yet hold (e.g. one this transaction will mint via a
814
+ // swap). Tempo's gas check runs before the calls execute, so the
815
+ // tx would revert at broadcast with `have 0 want N`. Validate the
816
+ // sender's pre-tx balance against the computed gas cost and, if
817
+ // short, autoSwap a token they do hold into the fee token.
818
+ if (autoSwap && !swap) {
819
+ const fromAddress = request['from'] as Address | undefined
820
+ const resolvedFeeToken = ((mergedTx['feeToken'] as Address | undefined) ?? feeToken) as
821
+ | Address
822
+ | undefined
823
+ const gas = mergedTx['gas'] ? BigInt(mergedTx['gas'] as `0x${string}`) : 0n
824
+ const maxFeePerGas = mergedTx['maxFeePerGas']
825
+ ? BigInt(mergedTx['maxFeePerGas'] as `0x${string}`)
826
+ : 0n
827
+ if (fromAddress && resolvedFeeToken && gas > 0n && maxFeePerGas > 0n) {
828
+ const [balance, metadata] = await Promise.all([
829
+ Actions.token
830
+ .getBalance(client, { account: fromAddress, token: resolvedFeeToken })
831
+ .then((balance) => balance.amount)
832
+ .catch(() => 0n),
833
+ resolveTokenMetadata(client, { token: resolvedFeeToken, store }).catch(() => undefined),
834
+ ])
835
+ if (metadata) {
836
+ const requiredFee =
837
+ (gas * maxFeePerGas) / 10n ** BigInt(Math.max(0, 18 - metadata.decimals))
838
+ if (balance < requiredFee) {
839
+ // Best-effort: if the swap itself can't be filled (e.g. the
840
+ // source token also has insufficient balance), fall through
841
+ // and return the original tx with the resolved feeToken
842
+ // rather than failing the whole request.
843
+ const swapResult = await fillWithSwap(resolvedFeeToken, requiredFee - balance).catch(
844
+ () => null,
845
+ )
846
+ if (swapResult) return swapResult
847
+ }
848
+ }
849
+ }
850
+ }
851
+
852
+ return {
853
+ transaction: Utils.normalizeTempoTransaction(mergedTx),
854
+ sponsor,
855
+ ...(swap ? { swap } : {}),
856
+ }
857
+ } catch (error) {
858
+ if (!(error instanceof Error)) throw error
859
+ if (!autoSwap) throw error
860
+
861
+ const revert = ExecutionError.parse(error)
862
+ if (revert?.errorName !== 'InsufficientBalance') throw error
863
+
864
+ const [available, required, token] = revert.args
865
+ if (typeof available === 'undefined' || typeof required === 'undefined' || !token) throw error
866
+
867
+ const swapResult = await fillWithSwap(token as Address, required - available)
868
+ if (!swapResult) throw error
869
+ return swapResult
870
+ }
871
+ }
872
+
873
+ declare namespace fill {
874
+ type Options = {
875
+ autoSwap?: { slippage: number } | undefined
876
+ feeToken?: Address | undefined
877
+ store?: Store.Store | undefined
878
+ resolveFeeToken?: ((insufficientToken: Address) => Promise<Address | undefined>) | undefined
879
+ transaction: Record<string, unknown>
880
+ }
881
+ }
882
+
883
+ async function resolveFeeToken(
884
+ client: Client,
885
+ options: resolveFeeToken.Options,
886
+ ): Promise<Address | undefined> {
887
+ const { feeToken, account, store, tokens } = options
888
+ if (feeToken) return feeToken
889
+ if (!account) return undefined
890
+
891
+ // Cache the user's preferred fee token for `userTokenCacheTtl` seconds.
892
+ // The on-chain preference rarely changes; a short TTL avoids the
893
+ // ~150-250ms `userTokens` multicall on every unsponsored fill while
894
+ // still picking up updates within a minute. The cached value is just a
895
+ // hint — if the user's balance is 0 we fall back to the highest-balance
896
+ // token from the configured list.
897
+ const getUserToken = () => Actions.fee.getUserToken(client, { account }).catch(() => null)
898
+ const userTokenPromise = store
899
+ ? Store.memoize(
900
+ async () => {
901
+ const result = await getUserToken()
902
+ return result ? { address: result.address } : null
903
+ },
904
+ {
905
+ key: `fee.userToken:${client.chain?.id ?? 0}:${account.toLowerCase()}`,
906
+ store,
907
+ ttl: (options.userTokenCacheTtl ?? 60) * 1000,
908
+ },
909
+ )
910
+ : getUserToken()
911
+
912
+ const [userToken, balances] = await Promise.all([
913
+ userTokenPromise,
914
+ tokens
915
+ ? Promise.all(
916
+ tokens.map(async (token) => ({
917
+ address: token,
918
+ balance: await Actions.token
919
+ .getBalance(client, { account, token })
920
+ .then((balance) => balance.amount)
921
+ .catch(() => 0n),
922
+ })),
923
+ )
924
+ : [],
925
+ ])
926
+
927
+ // If on-chain preference is set and user has balance, use it.
928
+ if (userToken) {
929
+ const match = balances.find(
930
+ (b: { address: Address; balance: bigint }) =>
931
+ b.address.toLowerCase() === userToken.address.toLowerCase() && b.balance > 0n,
932
+ )
933
+ if (match) return userToken.address
934
+
935
+ // Token list may not include the preference — check on-chain directly.
936
+ if (!match) {
937
+ try {
938
+ const { amount: balance } = await Actions.token.getBalance(client, {
939
+ account,
940
+ token: userToken.address,
941
+ })
942
+ if (balance > 0n) return userToken.address
943
+ } catch {}
944
+ }
945
+ }
946
+
947
+ // Pick the token with the highest balance.
948
+ let best: { address: Address; balance: bigint } | undefined
949
+ for (const asset of balances) {
950
+ if (asset.balance <= 0n) continue
951
+ if (!best || asset.balance > best.balance) best = asset
952
+ }
953
+ if (best) return best.address
954
+ return undefined
955
+ }
956
+
957
+ declare namespace resolveFeeToken {
958
+ type Options = {
959
+ feeToken?: Address | undefined
960
+ account?: Address | undefined
961
+ store?: Store.Store | undefined
962
+ tokens?: readonly Address[] | undefined
963
+ /** TTL in seconds for the cached `userTokens` lookup. @default 60 */
964
+ userTokenCacheTtl?: number | undefined
965
+ }
966
+ }
967
+
968
+ /**
969
+ * Extracts unique TIP20 token addresses (Tempo `0x20c0…` prefix) that the
970
+ * transaction's calls target. Used as fallback fee-token candidates so a
971
+ * user transferring a token they hold can pay gas in that token even when
972
+ * the configured fee-token list defaults to one they don't hold.
973
+ */
974
+ function callTargetTokens(transaction: Record<string, unknown>): readonly Address[] {
975
+ const calls = transaction['calls'] as readonly { to?: Address }[] | undefined
976
+ if (!calls) return []
977
+ const out: Address[] = []
978
+ const seen = new Set<string>()
979
+ for (const c of calls) {
980
+ if (!c.to) continue
981
+ const lower = c.to.toLowerCase()
982
+ if (!lower.startsWith('0x20c0')) continue
983
+ if (seen.has(lower)) continue
984
+ seen.add(lower)
985
+ out.push(c.to)
986
+ }
987
+ return out
988
+ }
989
+
990
+ // TODO: Replace with viem's Tempo fillTransaction capability type once released.
991
+ type VirtualAddresses = Record<Address, Address | null>
992
+
993
+ async function resolveVirtualAddresses(
994
+ client: Client,
995
+ options: { calls: readonly Call[] },
996
+ ): Promise<VirtualAddresses | undefined> {
997
+ const targets = getVirtualAddressTargets(options.calls)
998
+ if (targets.length === 0) return undefined
999
+
1000
+ const masters = new Map<string, { addresses: Address[]; masterId: Hex.Hex }>()
1001
+ for (const address of targets) {
1002
+ const { masterId } = VirtualAddress.parse(address)
1003
+ const lower = masterId.toLowerCase()
1004
+ const entry = masters.get(lower) ?? { addresses: [], masterId }
1005
+ entry.addresses.push(address)
1006
+ masters.set(lower, entry)
1007
+ }
1008
+
1009
+ const entries = await Promise.all(
1010
+ [...masters.values()].map(async ({ addresses, masterId }) => {
1011
+ const master = await Actions.virtualAddress.getMasterAddress(client, { masterId })
1012
+ return addresses.map((address) => [address, master] as const)
1013
+ }),
1014
+ )
1015
+ return Object.fromEntries(entries.flat()) as VirtualAddresses
1016
+ }
1017
+
1018
+ function getVirtualAddressTargets(calls: readonly Call[]): readonly Address[] {
1019
+ const targets = new Set<Address>()
1020
+ for (const call of calls) {
1021
+ for (const address of [call.to, decodeTransferRecipient(call.data)]) {
1022
+ if (!address || !isAddress(address) || !VirtualAddress.isVirtual(address)) continue
1023
+ targets.add(address.toLowerCase() as Address)
1024
+ }
1025
+ }
1026
+ return [...targets]
1027
+ }
1028
+
1029
+ function decodeTransferRecipient(data?: string): Address | undefined {
1030
+ if (!data) return undefined
1031
+ const selector = data.slice(0, 10).toLowerCase()
1032
+ if (!transferSelectors.has(selector)) return undefined
1033
+ try {
1034
+ const { args, functionName } = decodeFunctionData({
1035
+ abi: Abis.tip20,
1036
+ data: data as Hex.Hex,
1037
+ })
1038
+ if (
1039
+ (functionName === 'transfer' || functionName === 'transferWithMemo') &&
1040
+ typeof args[0] === 'string'
1041
+ )
1042
+ return args[0]
1043
+ if (
1044
+ (functionName === 'transferFrom' || functionName === 'transferFromWithMemo') &&
1045
+ typeof args[1] === 'string'
1046
+ )
1047
+ return args[1]
1048
+ } catch {}
1049
+ return undefined
1050
+ }
1051
+
1052
+ const transferSelectors = new Set(['0xa9059cbb', '0x95777d59', '0x23b872dd', '0x929c2539'])
1053
+
1054
+ // TODO: cleanup/remove
1055
+ function extractCalls(transaction: Record<string, unknown>): readonly Call[] {
1056
+ const calls = transaction['calls'] as readonly Call[] | undefined
1057
+ if (calls && calls.length > 0)
1058
+ return calls.map((c) => ({
1059
+ ...(c.to ? { to: c.to } : {}),
1060
+ ...(c.data ? { data: c.data } : {}),
1061
+ ...(c.value ? { value: c.value } : {}),
1062
+ })) as readonly Call[]
1063
+ return [
1064
+ {
1065
+ ...(transaction['to'] ? { to: transaction['to'] as Address } : {}),
1066
+ ...(transaction['data'] ? { data: transaction['data'] as `0x${string}` } : {}),
1067
+ ...(transaction['value'] ? { value: transaction['value'] as bigint } : {}),
1068
+ },
1069
+ ] as readonly Call[]
1070
+ }
1071
+
1072
+ // TODO: cleanup/remove
1073
+ async function simulate(client: Client, options: simulate.Options) {
1074
+ const { account, calls } = options
1075
+ try {
1076
+ return await Actions.simulate.simulateCalls(client, {
1077
+ ...(account ? { account } : {}),
1078
+ calls: calls as Call[],
1079
+ traceTransfers: true,
1080
+ })
1081
+ } catch (error) {
1082
+ // TODO: Remove fallback once all nodes support tempo_simulateV1.
1083
+ // Fall back to viem's simulateCalls (eth_simulateV1) if the Tempo
1084
+ // method (tempo_simulateV1) is not supported.
1085
+ const code =
1086
+ (error as { code?: number | undefined }).code ??
1087
+ (error as { cause?: { code?: number | undefined } | undefined }).cause?.code
1088
+ if (code !== -32601) throw error
1089
+ const { results } = await simulateCalls(client, {
1090
+ ...(account ? { account } : {}),
1091
+ calls: calls as Call[],
1092
+ })
1093
+ return { results, tokenMetadata: undefined }
1094
+ }
1095
+ }
1096
+
1097
+ declare namespace simulate {
1098
+ type Options = {
1099
+ account?: Address | undefined
1100
+ calls: readonly Call[]
1101
+ }
1102
+ }
1103
+
1104
+ async function simulateAndParseDiffs(client: Client, options: simulateAndParseDiffs.Options) {
1105
+ const { account, calls, swap, feeToken, gas, store, maxFeePerGas } = options
1106
+
1107
+ try {
1108
+ const { results, tokenMetadata } = await simulate(client, {
1109
+ account: account === zeroAddress ? undefined : account,
1110
+ calls,
1111
+ })
1112
+
1113
+ // Collect all logs across all call results.
1114
+ const logs: (typeof results)[number]['logs'] = []
1115
+ for (const result of results as { logs?: (typeof logs)[number][] | undefined }[])
1116
+ if (result.logs) logs.push(...result.logs)
1117
+
1118
+ // Build per-token balance diffs relative to the sender.
1119
+ const balanceDiffs = account
1120
+ ? await buildBalanceDiffs(client, {
1121
+ account,
1122
+ store,
1123
+ logs,
1124
+ swap,
1125
+ tokenMetadata: tokenMetadata as never,
1126
+ })
1127
+ : {}
1128
+
1129
+ // Compute fee breakdown.
1130
+ const fee = await computeFee(client, {
1131
+ feeToken,
1132
+ gas,
1133
+ store,
1134
+ maxFeePerGas,
1135
+ tokenMetadata: tokenMetadata as never,
1136
+ }).catch(() => undefined)
1137
+
1138
+ return { balanceDiffs, fee }
1139
+ } catch {
1140
+ // Simulation failures should not block the fill response —
1141
+ // return empty diffs with fee computed from transaction fields.
1142
+ const fee = await computeFee(client, { feeToken, gas, store, maxFeePerGas })
1143
+ return { balanceDiffs: undefined, fee }
1144
+ }
1145
+ }
1146
+
1147
+ declare namespace simulateAndParseDiffs {
1148
+ type Options = {
1149
+ account?: Address | undefined
1150
+ calls: readonly Call[]
1151
+ swap?: { tokenIn: Address; tokenOut: Address } | undefined
1152
+ feeToken?: Address | undefined
1153
+ gas?: bigint | undefined
1154
+ store?: Store.Store | undefined
1155
+ maxFeePerGas?: bigint | undefined
1156
+ }
1157
+ }
1158
+
1159
+ async function buildBalanceDiffs(client: Client, options: buildBalanceDiffs.Options) {
1160
+ const { account, store, logs, swap, tokenMetadata } = options
1161
+ const accountLower = account.toLowerCase()
1162
+ const dexLower = Addresses.stablecoinDex.toLowerCase()
1163
+ const swapTokenIn = swap?.tokenIn.toLowerCase()
1164
+ const swapTokenOut = swap?.tokenOut.toLowerCase()
1165
+
1166
+ const transferLogs = parseEventLogs({
1167
+ abi: [AbiEvent.fromAbi(Abis.tip20, 'Transfer')],
1168
+ eventName: 'Transfer',
1169
+ logs,
1170
+ })
1171
+ const approvalLogs = parseEventLogs({
1172
+ abi: [AbiEvent.fromAbi(Abis.tip20, 'Approval')],
1173
+ eventName: 'Approval',
1174
+ logs,
1175
+ })
1176
+
1177
+ // Track net movement per token: incoming vs outgoing.
1178
+ const tokenMap = new Map<
1179
+ string,
1180
+ { incoming: bigint; outgoing: bigint; recipients: Set<Address>; token: Address }
1181
+ >()
1182
+
1183
+ // Track total transferred per (token, spender) so we can suppress covered approvals.
1184
+ const transferredBySpender = new Map<string, bigint>()
1185
+
1186
+ for (const log of transferLogs) {
1187
+ const token = log.address.toLowerCase()
1188
+ const fromLower = log.args.from.toLowerCase()
1189
+ const toLower = log.args.to.toLowerCase()
1190
+
1191
+ // Skip swap-related transfers (reported in capabilities.autoSwap instead).
1192
+ if (swap) {
1193
+ if (token === swapTokenIn && fromLower === accountLower && toLower === dexLower) continue
1194
+ if (token === swapTokenOut && fromLower === dexLower && toLower === accountLower) continue
1195
+ }
1196
+
1197
+ const entry = tokenMap.get(token) ?? {
1198
+ incoming: 0n,
1199
+ outgoing: 0n,
1200
+ recipients: new Set<Address>(),
1201
+ token: log.address,
1202
+ }
1203
+ if (fromLower === accountLower) {
1204
+ entry.outgoing += log.args.amount
1205
+ entry.recipients.add(log.args.to)
1206
+ const key = `${token}:${toLower}`
1207
+ transferredBySpender.set(key, (transferredBySpender.get(key) ?? 0n) + log.args.amount)
1208
+ }
1209
+ if (toLower === accountLower) entry.incoming += log.args.amount
1210
+ tokenMap.set(token, entry)
1211
+ }
1212
+
1213
+ // Treat approvals as outgoing unless the spender already transferred >= approval amount.
1214
+ for (const log of approvalLogs) {
1215
+ if (log.args.owner.toLowerCase() !== accountLower) continue
1216
+ const token = log.address.toLowerCase()
1217
+
1218
+ // Skip swap-related approvals (reported in capabilities.autoSwap instead).
1219
+ if (swap && token === swapTokenIn && log.args.spender.toLowerCase() === dexLower) continue
1220
+
1221
+ const spenderKey = `${token}:${log.args.spender.toLowerCase()}`
1222
+ const transferred = transferredBySpender.get(spenderKey) ?? 0n
1223
+ if (log.args.amount <= transferred) continue
1224
+
1225
+ const entry = tokenMap.get(token) ?? {
1226
+ incoming: 0n,
1227
+ outgoing: 0n,
1228
+ recipients: new Set<Address>(),
1229
+ token: log.address,
1230
+ }
1231
+ entry.outgoing += log.args.amount - transferred
1232
+ entry.recipients.add(log.args.spender)
1233
+ tokenMap.set(token, entry)
1234
+ }
1235
+
1236
+ // Collect unique tokens that need decimals.
1237
+ const entries = [...tokenMap.values()].filter((e) => {
1238
+ const net = e.outgoing > e.incoming ? e.outgoing - e.incoming : e.incoming - e.outgoing
1239
+ return net > 0n
1240
+ })
1241
+ if (entries.length === 0) return {}
1242
+
1243
+ // Resolve metadata for all tokens in parallel (simulation metadata first, RPC fallback).
1244
+ const metadataMap = new Map<string, { decimals: number; symbol: string; name: string }>()
1245
+ await Promise.all(
1246
+ entries.map(async (entry) => {
1247
+ try {
1248
+ const metadata = await resolveTokenMetadata(client, {
1249
+ token: entry.token,
1250
+ tokenMetadata,
1251
+ store,
1252
+ })
1253
+ metadataMap.set(entry.token.toLowerCase(), metadata)
1254
+ } catch {}
1255
+ }),
1256
+ )
1257
+
1258
+ // Build the diff array for this account.
1259
+ const diffs: Capabilities.BalanceDiff[] = []
1260
+ for (const entry of entries) {
1261
+ const net =
1262
+ entry.outgoing > entry.incoming
1263
+ ? entry.outgoing - entry.incoming
1264
+ : entry.incoming - entry.outgoing
1265
+
1266
+ const direction = entry.outgoing > entry.incoming ? 'outgoing' : 'incoming'
1267
+ const meta = metadataMap.get(entry.token.toLowerCase())
1268
+ const decimals = meta?.decimals ?? 0
1269
+ diffs.push({
1270
+ address: entry.token,
1271
+ decimals,
1272
+ direction,
1273
+ formatted: formatUnits(net, decimals),
1274
+ name: meta?.name ?? '',
1275
+ symbol: meta?.symbol ?? '',
1276
+ recipients: [...entry.recipients] as Address[],
1277
+ value: Hex.fromNumber(net) as `0x${string}`,
1278
+ })
1279
+ }
1280
+
1281
+ return { [account]: diffs }
1282
+ }
1283
+
1284
+ declare namespace buildBalanceDiffs {
1285
+ type Options = {
1286
+ account: Address
1287
+ store?: Store.Store | undefined
1288
+ logs: Log[]
1289
+ swap?: { tokenIn: Address; tokenOut: Address } | undefined
1290
+ tokenMetadata: Record<Address, { name: string; symbol: string; currency: string }>
1291
+ }
1292
+ }
1293
+
1294
+ async function resolveTokenMetadata(client: Client, options: resolveTokenMetadata.Options) {
1295
+ const { token, tokenMetadata, store } = options
1296
+ const meta = tokenMetadata?.[token] ?? tokenMetadata?.[token.toLowerCase() as Address]
1297
+ // TIP-20 metadata (decimals/symbol/name) is immutable per token, so cache
1298
+ // long-term. Skips the multicall RPC on cache hits.
1299
+ const fetcher = () => Actions.token.getMetadata(client, { token })
1300
+ const fallback = store
1301
+ ? await Store.memoize(fetcher, {
1302
+ key: `tokenMetadata:${client.chain?.id ?? 0}:${token.toLowerCase()}`,
1303
+ store,
1304
+ ttl: 24 * 60 * 60 * 1000,
1305
+ })
1306
+ : await fetcher()
1307
+ return {
1308
+ decimals: fallback.decimals ?? 6,
1309
+ symbol: meta?.symbol || fallback.symbol,
1310
+ name: meta?.name || fallback.name,
1311
+ }
1312
+ }
1313
+
1314
+ declare namespace resolveTokenMetadata {
1315
+ type Options = {
1316
+ token: Address
1317
+ tokenMetadata?: Record<Address, { name: string; symbol: string; currency: string }> | undefined
1318
+ store?: Store.Store | undefined
1319
+ }
1320
+ }
1321
+
1322
+ async function resolveAutoSwapMetadata(client: Client, options: resolveAutoSwapMetadata.Options) {
1323
+ const { autoSwap, store, swap } = options
1324
+ if (!autoSwap || !swap) return undefined
1325
+ const [inMeta, outMeta] = await Promise.all([
1326
+ resolveTokenMetadata(client, { token: swap.tokenIn, store }).catch(() => undefined),
1327
+ resolveTokenMetadata(client, { token: swap.tokenOut, store }).catch(() => undefined),
1328
+ ])
1329
+ if (!inMeta || !outMeta) return undefined
1330
+ return {
1331
+ calls: swap.calls.map((c) => ({ to: c.to, data: c.data })),
1332
+ slippage: autoSwap.slippage,
1333
+ maxIn: {
1334
+ token: swap.tokenIn,
1335
+ value: Hex.fromNumber(swap.maxAmountIn) as `0x${string}`,
1336
+ formatted: formatUnits(swap.maxAmountIn, inMeta.decimals),
1337
+ decimals: inMeta.decimals,
1338
+ symbol: inMeta.symbol,
1339
+ name: inMeta.name,
1340
+ },
1341
+ minOut: {
1342
+ token: swap.tokenOut,
1343
+ value: Hex.fromNumber(swap.amountOut) as `0x${string}`,
1344
+ formatted: formatUnits(swap.amountOut, outMeta.decimals),
1345
+ decimals: outMeta.decimals,
1346
+ symbol: outMeta.symbol,
1347
+ name: outMeta.name,
1348
+ },
1349
+ }
1350
+ }
1351
+
1352
+ declare namespace resolveAutoSwapMetadata {
1353
+ type Options = {
1354
+ autoSwap?: { slippage: number } | undefined
1355
+ store?: Store.Store | undefined
1356
+ swap?:
1357
+ | {
1358
+ calls: readonly { to: Address; data: `0x${string}` }[]
1359
+ tokenIn: Address
1360
+ tokenOut: Address
1361
+ amountOut: bigint
1362
+ maxAmountIn: bigint
1363
+ }
1364
+ | undefined
1365
+ }
1366
+ }
1367
+
1368
+ async function computeFee(client: Client, options: computeFee.Options) {
1369
+ const { feeToken, gas, store, maxFeePerGas, tokenMetadata } = options
1370
+ if (!feeToken || !gas || !maxFeePerGas) return undefined
1371
+
1372
+ try {
1373
+ const metadata = await resolveTokenMetadata(client, { token: feeToken, tokenMetadata, store })
1374
+ const raw = gas * maxFeePerGas
1375
+ const amount = raw / 10n ** BigInt(18 - metadata.decimals)
1376
+ return {
1377
+ amount: Hex.fromNumber(amount) as `0x${string}`,
1378
+ decimals: metadata.decimals,
1379
+ formatted: formatUnits(amount, metadata.decimals),
1380
+ symbol: metadata.symbol,
1381
+ }
1382
+ } catch {
1383
+ return undefined
1384
+ }
1385
+ }
1386
+
1387
+ declare namespace computeFee {
1388
+ type Options = {
1389
+ feeToken?: Address | undefined
1390
+ gas?: bigint | undefined
1391
+ store?: Store.Store | undefined
1392
+ maxFeePerGas?: bigint | undefined
1393
+ tokenMetadata?: Record<Address, { name: string; symbol: string; currency: string }> | undefined
1394
+ }
1395
+ }
1396
+
1397
+ function buildSwapCalls(
1398
+ client: Client,
1399
+ sourceToken: Address,
1400
+ targetToken: Address,
1401
+ deficit: bigint,
1402
+ maxAmountIn: bigint,
1403
+ ) {
1404
+ const approve = Actions.token.approve.call(client, {
1405
+ token: sourceToken,
1406
+ spender: Addresses.stablecoinDex,
1407
+ amount: maxAmountIn,
1408
+ })
1409
+ const buy = Actions.dex.buy.call({
1410
+ tokenIn: sourceToken,
1411
+ tokenOut: targetToken,
1412
+ amountOut: deficit,
1413
+ maxAmountIn,
1414
+ })
1415
+ return [
1416
+ { to: approve.to, data: approve.data, value: 0n },
1417
+ { to: buy.to, data: buy.data, value: 0n },
1418
+ ] as const
1419
+ }
1420
+
1421
+ /**
1422
+ * Merges the original fill request into the result tx. The chain's
1423
+ * `eth_fillTransaction` returns only the "filled" gas/nonce/fee fields and
1424
+ * omits envelope inputs like `calls`, `chainId`, `validBefore`, `nonceKey`,
1425
+ * `keyData`, `keyType`, `feePayer`. Without these the typed Tempo envelope
1426
+ * built for sponsorship signing throws `CallsEmptyError` or
1427
+ * `Cannot convert undefined to a BigInt` when serializing.
1428
+ *
1429
+ * Result fields take precedence (they are the chain's authoritative filled
1430
+ * values); request fields fill in everything else. Calls are normalized
1431
+ * separately so legacy `to`/`data`/`value` requests are also supported.
1432
+ */
1433
+ function mergeCallsFromRequest(
1434
+ resultTx: Record<string, unknown>,
1435
+ request: Record<string, unknown>,
1436
+ ): Record<string, unknown> {
1437
+ const merged: Record<string, unknown> = { ...request, ...resultTx }
1438
+ const resultCalls = resultTx['calls']
1439
+ if (Array.isArray(resultCalls) && resultCalls.length > 0) return merged
1440
+
1441
+ const reqCalls = request['calls']
1442
+ if (Array.isArray(reqCalls) && reqCalls.length > 0) {
1443
+ merged['calls'] = reqCalls
1444
+ return merged
1445
+ }
1446
+
1447
+ const { to, data, value } = request
1448
+ if (typeof to === 'undefined' && typeof data === 'undefined' && typeof value === 'undefined')
1449
+ return merged
1450
+
1451
+ merged['calls'] = [
1452
+ {
1453
+ ...(typeof to !== 'undefined' ? { to } : {}),
1454
+ ...(typeof data !== 'undefined' ? { data } : {}),
1455
+ ...(typeof value !== 'undefined' ? { value } : {}),
1456
+ },
1457
+ ]
1458
+ return merged
1459
+ }
1460
+
1461
+ /** Normalizes external fee-payer relay URLs before forwarding fill payloads. */
1462
+ function normalizeExternalFeePayerUrl(
1463
+ value: string,
1464
+ options: normalizeExternalFeePayerUrl.Options = {},
1465
+ ): string {
1466
+ let url: URL
1467
+ try {
1468
+ url = new URL(value)
1469
+ } catch {
1470
+ throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL.' })
1471
+ }
1472
+
1473
+ const allowUnsafe = options.allowUnsafe === true
1474
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
1475
+ throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL protocol.' })
1476
+ if (!allowUnsafe && url.protocol !== 'https:')
1477
+ throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL protocol.' })
1478
+ if (!allowUnsafe && isUnsafeHostname(url.hostname))
1479
+ throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL host.' })
1480
+
1481
+ url.username = ''
1482
+ url.password = ''
1483
+ url.hash = ''
1484
+ return url.href
1485
+ }
1486
+
1487
+ declare namespace normalizeExternalFeePayerUrl {
1488
+ type Options = {
1489
+ /** Whether to allow non-HTTPS or local/private external fee payer URLs. */
1490
+ allowUnsafe?: boolean | undefined
1491
+ }
1492
+ }
1493
+
1494
+ function isUnsafeHostname(hostname: string): boolean {
1495
+ const host = normalizeHostname(hostname)
1496
+ if (host === 'localhost' || host.endsWith('.localhost')) return true
1497
+ const ipv4 = parseIpv4(host)
1498
+ if (ipv4) return isUnsafeIpv4(ipv4)
1499
+ if (!host.includes(':')) return false
1500
+ const ipv6 = parseIpv6(host)
1501
+ if (!ipv6) return true
1502
+ return isUnsafeIpv6(ipv6)
1503
+ }
1504
+
1505
+ function normalizeHostname(hostname: string): string {
1506
+ return hostname
1507
+ .toLowerCase()
1508
+ .replace(/^\[|\]$/g, '')
1509
+ .split('%')[0]!
1510
+ }
1511
+
1512
+ function parseIpv4(value: string): [number, number, number, number] | undefined {
1513
+ const parts = value.split('.')
1514
+ if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) return undefined
1515
+ const numbers = parts.map((part) => Number(part))
1516
+ if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return undefined
1517
+ return numbers as [number, number, number, number]
1518
+ }
1519
+
1520
+ function isUnsafeIpv4(parts: [number, number, number, number]): boolean {
1521
+ const [a, b] = parts
1522
+ if (a === 0 || a === 10 || a === 127) return true
1523
+ if (a === 100 && b >= 64 && b <= 127) return true
1524
+ if (a === 169 && b === 254) return true
1525
+ if (a === 172 && b >= 16 && b <= 31) return true
1526
+ if (a === 192 && b === 168) return true
1527
+ if (a >= 224) return true
1528
+ return false
1529
+ }
1530
+
1531
+ function parseIpv6(
1532
+ value: string,
1533
+ ): [number, number, number, number, number, number, number, number] | undefined {
1534
+ const halves = value.split('::')
1535
+ if (halves.length > 2) return undefined
1536
+
1537
+ const left = halves[0] ? halves[0].split(':') : []
1538
+ const right = halves[1] ? halves[1].split(':') : []
1539
+ const missing = halves.length === 2 ? 8 - left.length - right.length : 0
1540
+ if (missing < 0) return undefined
1541
+
1542
+ const parts = [...left, ...Array(missing).fill('0'), ...right]
1543
+ if (parts.length !== 8) return undefined
1544
+
1545
+ const numbers = parts.map((part) =>
1546
+ /^[\da-f]{1,4}$/i.test(part) ? Number.parseInt(part, 16) : NaN,
1547
+ )
1548
+ if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) return undefined
1549
+ return numbers as [number, number, number, number, number, number, number, number]
1550
+ }
1551
+
1552
+ function isUnsafeIpv6(parts: [number, number, number, number, number, number, number, number]) {
1553
+ const [a] = parts
1554
+ if (parts.every((part) => part === 0)) return true
1555
+ if (parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1) return true
1556
+ if ((a & 0xfe00) === 0xfc00) return true
1557
+ if ((a & 0xffc0) === 0xfe80) return true
1558
+ if ((a & 0xffc0) === 0xfec0) return true
1559
+ if ((a & 0xff00) === 0xff00) return true
1560
+
1561
+ const ipv4 = ipv4FromIpv6(parts)
1562
+ if (ipv4) return isUnsafeIpv4(ipv4)
1563
+ return false
1564
+ }
1565
+
1566
+ function ipv4FromIpv6(
1567
+ parts: [number, number, number, number, number, number, number, number],
1568
+ ): [number, number, number, number] | undefined {
1569
+ const mapped = parts.slice(0, 5).every((part) => part === 0) && parts[5] === 0xffff
1570
+ const compatible = parts.slice(0, 6).every((part) => part === 0)
1571
+ if (!mapped && !compatible) return undefined
1572
+ return [parts[6]! >> 8, parts[6]! & 0xff, parts[7]! >> 8, parts[7]! & 0xff]
1573
+ }
1574
+
1575
+ /**
1576
+ * Reconstructs a `swap` shape (matching the inner autoSwap branch's return
1577
+ * value) from an upstream relay's `capabilities.autoSwap`. Used so a wallet
1578
+ * relay forwarding to an external feePayer URL can re-emit autoSwap metadata
1579
+ * locally without losing track of the upstream's swap.
1580
+ */
1581
+ function extractSwapFromCapabilities(autoSwap: unknown):
1582
+ | {
1583
+ calls: readonly { to: Address; data: `0x${string}` }[]
1584
+ tokenIn: Address
1585
+ tokenOut: Address
1586
+ amountOut: bigint
1587
+ maxAmountIn: bigint
1588
+ }
1589
+ | undefined {
1590
+ if (!autoSwap || typeof autoSwap !== 'object') return undefined
1591
+ const a = autoSwap as {
1592
+ calls?: readonly { to: Address; data: `0x${string}` }[]
1593
+ maxIn?: { token?: Address; value?: `0x${string}` }
1594
+ minOut?: { token?: Address; value?: `0x${string}` }
1595
+ }
1596
+ if (!a.calls || !a.maxIn?.token || !a.maxIn.value || !a.minOut?.token || !a.minOut.value)
1597
+ return undefined
1598
+ return {
1599
+ calls: a.calls,
1600
+ tokenIn: a.maxIn.token,
1601
+ tokenOut: a.minOut.token,
1602
+ amountOut: BigInt(a.minOut.value),
1603
+ maxAmountIn: BigInt(a.maxIn.value),
1604
+ }
1605
+ }