tapimo 0.2.0 → 0.3.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 (313) hide show
  1. package/README.md +31 -3
  2. package/dist/ApiKey.d.ts +1 -0
  3. package/dist/ApiKey.d.ts.map +1 -1
  4. package/dist/ApiKey.js +5 -2
  5. package/dist/ApiKey.js.map +1 -1
  6. package/dist/ApiKeys.d.ts +28 -1
  7. package/dist/ApiKeys.d.ts.map +1 -1
  8. package/dist/ApiKeys.js +34 -1
  9. package/dist/ApiKeys.js.map +1 -1
  10. package/dist/App.d.ts +7167 -4507
  11. package/dist/App.d.ts.map +1 -1
  12. package/dist/App.js +8 -0
  13. package/dist/App.js.map +1 -1
  14. package/dist/Scope.d.ts +11 -2
  15. package/dist/Scope.d.ts.map +1 -1
  16. package/dist/Scope.js +14 -3
  17. package/dist/Scope.js.map +1 -1
  18. package/dist/admin/App.d.ts +128 -118
  19. package/dist/admin/App.d.ts.map +1 -1
  20. package/dist/admin/apps/api-keys.d.ts +7 -0
  21. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  22. package/dist/admin/apps/scopes.d.ts +8 -0
  23. package/dist/admin/apps/scopes.d.ts.map +1 -1
  24. package/dist/admin/apps/scopes.js +3 -0
  25. package/dist/admin/apps/scopes.js.map +1 -1
  26. package/dist/admin/apps/verified-tokens.d.ts +57 -57
  27. package/dist/admin/ui.generated.d.ts +1 -1
  28. package/dist/admin/ui.generated.js +13 -13
  29. package/dist/admin/ui.generated.js.map +1 -1
  30. package/dist/analytics/Analytics.d.ts +151 -0
  31. package/dist/analytics/Analytics.d.ts.map +1 -0
  32. package/dist/analytics/Analytics.js +212 -0
  33. package/dist/analytics/Analytics.js.map +1 -0
  34. package/dist/analytics/tables/requestEvents.d.ts +215 -0
  35. package/dist/analytics/tables/requestEvents.d.ts.map +1 -0
  36. package/dist/analytics/tables/requestEvents.js +264 -0
  37. package/dist/analytics/tables/requestEvents.js.map +1 -0
  38. package/dist/apps/data/App.d.ts +1024 -624
  39. package/dist/apps/data/App.d.ts.map +1 -1
  40. package/dist/apps/data/App.js +36 -0
  41. package/dist/apps/data/App.js.map +1 -1
  42. package/dist/apps/data/routes/balances.d.ts +45 -45
  43. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  44. package/dist/apps/data/routes/exchanges.d.ts +455 -455
  45. package/dist/apps/data/routes/fee-amm.d.ts +101 -101
  46. package/dist/apps/data/routes/funding.d.ts +765 -0
  47. package/dist/apps/data/routes/funding.d.ts.map +1 -0
  48. package/dist/apps/data/routes/funding.js +383 -0
  49. package/dist/apps/data/routes/funding.js.map +1 -0
  50. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  51. package/dist/apps/data/routes/receipts.js +2 -8
  52. package/dist/apps/data/routes/receipts.js.map +1 -1
  53. package/dist/apps/data/routes/rpc.d.ts +29 -36
  54. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  55. package/dist/apps/data/routes/rpc.js +29 -1
  56. package/dist/apps/data/routes/rpc.js.map +1 -1
  57. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  58. package/dist/apps/data/routes/transfers.d.ts +56 -56
  59. package/dist/apps/data/routes/verified-tokens.d.ts +36 -36
  60. package/dist/apps/data/routes/webhooks.d.ts +45 -45
  61. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  62. package/dist/apps/data/routes/webhooks.js +8 -8
  63. package/dist/apps/data/routes/webhooks.js.map +1 -1
  64. package/dist/apps/docs/App.d.ts +4 -2
  65. package/dist/apps/docs/App.d.ts.map +1 -1
  66. package/dist/apps/docs/App.js +27 -95
  67. package/dist/apps/docs/App.js.map +1 -1
  68. package/dist/apps/docs/reference.d.ts +21 -0
  69. package/dist/apps/docs/reference.d.ts.map +1 -0
  70. package/dist/apps/docs/reference.js +112 -0
  71. package/dist/apps/docs/reference.js.map +1 -0
  72. package/dist/apps/index.d.ts +1 -0
  73. package/dist/apps/index.d.ts.map +1 -1
  74. package/dist/apps/index.js +1 -0
  75. package/dist/apps/index.js.map +1 -1
  76. package/dist/apps/mcp.d.ts +76 -0
  77. package/dist/apps/mcp.d.ts.map +1 -0
  78. package/dist/apps/mcp.js +188 -0
  79. package/dist/apps/mcp.js.map +1 -0
  80. package/dist/apps/platform/App.d.ts +3005 -728
  81. package/dist/apps/platform/App.d.ts.map +1 -1
  82. package/dist/apps/platform/App.js +13 -3
  83. package/dist/apps/platform/App.js.map +1 -1
  84. package/dist/apps/platform/Billing.d.ts +279 -0
  85. package/dist/apps/platform/Billing.d.ts.map +1 -0
  86. package/dist/apps/platform/Billing.js +477 -0
  87. package/dist/apps/platform/Billing.js.map +1 -0
  88. package/dist/apps/platform/index.d.ts +1 -0
  89. package/dist/apps/platform/index.d.ts.map +1 -1
  90. package/dist/apps/platform/index.js +1 -0
  91. package/dist/apps/platform/index.js.map +1 -1
  92. package/dist/apps/platform/routes/api-keys.d.ts +341 -49
  93. package/dist/apps/platform/routes/api-keys.d.ts.map +1 -1
  94. package/dist/apps/platform/routes/api-keys.js +115 -13
  95. package/dist/apps/platform/routes/api-keys.js.map +1 -1
  96. package/dist/apps/platform/routes/billing.d.ts +1520 -0
  97. package/dist/apps/platform/routes/billing.d.ts.map +1 -0
  98. package/dist/apps/platform/routes/billing.js +795 -0
  99. package/dist/apps/platform/routes/billing.js.map +1 -0
  100. package/dist/apps/platform/routes/invitations.d.ts +201 -83
  101. package/dist/apps/platform/routes/invitations.d.ts.map +1 -1
  102. package/dist/apps/platform/routes/invitations.js +58 -0
  103. package/dist/apps/platform/routes/invitations.js.map +1 -1
  104. package/dist/apps/platform/routes/me.d.ts +17 -17
  105. package/dist/apps/platform/routes/orgs.d.ts +50 -36
  106. package/dist/apps/platform/routes/orgs.d.ts.map +1 -1
  107. package/dist/apps/platform/routes/orgs.js +14 -3
  108. package/dist/apps/platform/routes/orgs.js.map +1 -1
  109. package/dist/apps/platform/routes/projects.d.ts +97 -97
  110. package/dist/apps/platform/routes/scopes.d.ts +107 -0
  111. package/dist/apps/platform/routes/scopes.d.ts.map +1 -0
  112. package/dist/apps/platform/routes/scopes.js +66 -0
  113. package/dist/apps/platform/routes/scopes.js.map +1 -0
  114. package/dist/apps/platform/routes/usage.d.ts +588 -0
  115. package/dist/apps/platform/routes/usage.d.ts.map +1 -0
  116. package/dist/apps/platform/routes/usage.js +391 -0
  117. package/dist/apps/platform/routes/usage.js.map +1 -0
  118. package/dist/apps/relay/App.d.ts +4 -1
  119. package/dist/apps/relay/App.d.ts.map +1 -1
  120. package/dist/apps/relay/App.js +118 -10
  121. package/dist/apps/relay/App.js.map +1 -1
  122. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  123. package/dist/apps/relay/Sponsorships.js +2 -1
  124. package/dist/apps/relay/Sponsorships.js.map +1 -1
  125. package/dist/cli/Cli.d.ts +8 -1
  126. package/dist/cli/Cli.d.ts.map +1 -1
  127. package/dist/cli/Cli.js +11 -3
  128. package/dist/cli/Cli.js.map +1 -1
  129. package/dist/cli/commands/admin.d.ts +4 -0
  130. package/dist/cli/commands/admin.d.ts.map +1 -1
  131. package/dist/cli/commands/admin.js +80 -1
  132. package/dist/cli/commands/admin.js.map +1 -1
  133. package/dist/db/Db.d.ts +7 -0
  134. package/dist/db/Db.d.ts.map +1 -1
  135. package/dist/db/Db.js +126 -0
  136. package/dist/db/Db.js.map +1 -1
  137. package/dist/db/Schema.d.ts +79 -0
  138. package/dist/db/Schema.d.ts.map +1 -1
  139. package/dist/db/Schema.js +88 -2
  140. package/dist/db/Schema.js.map +1 -1
  141. package/dist/db/tables/billingSettings.d.ts +55 -0
  142. package/dist/db/tables/billingSettings.d.ts.map +1 -0
  143. package/dist/db/tables/billingSettings.js +70 -0
  144. package/dist/db/tables/billingSettings.js.map +1 -0
  145. package/dist/db/tables/invitations.js +2 -2
  146. package/dist/db/tables/invitations.js.map +1 -1
  147. package/dist/db/tables/organizations.d.ts +6 -2
  148. package/dist/db/tables/organizations.d.ts.map +1 -1
  149. package/dist/db/tables/organizations.js +10 -4
  150. package/dist/db/tables/organizations.js.map +1 -1
  151. package/dist/db/tables/projects.js +2 -2
  152. package/dist/db/tables/projects.js.map +1 -1
  153. package/dist/db/tables/requestUsage.d.ts +121 -0
  154. package/dist/db/tables/requestUsage.d.ts.map +1 -0
  155. package/dist/db/tables/requestUsage.js +150 -0
  156. package/dist/db/tables/requestUsage.js.map +1 -0
  157. package/dist/db/tables/sponsoredTransactions.d.ts +119 -0
  158. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  159. package/dist/db/tables/sponsoredTransactions.js +155 -2
  160. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  161. package/dist/db/tables/stripeCustomers.d.ts +81 -0
  162. package/dist/db/tables/stripeCustomers.d.ts.map +1 -0
  163. package/dist/db/tables/stripeCustomers.js +120 -0
  164. package/dist/db/tables/stripeCustomers.js.map +1 -0
  165. package/dist/db/tables/users.js +2 -2
  166. package/dist/db/tables/users.js.map +1 -1
  167. package/dist/handlers/internal/sponsorship.d.ts +5 -2
  168. package/dist/handlers/internal/sponsorship.d.ts.map +1 -1
  169. package/dist/handlers/internal/sponsorship.js +19 -5
  170. package/dist/handlers/internal/sponsorship.js.map +1 -1
  171. package/dist/handlers/internal/utils.d.ts +10 -3
  172. package/dist/handlers/internal/utils.d.ts.map +1 -1
  173. package/dist/handlers/internal/utils.js +14 -6
  174. package/dist/handlers/internal/utils.js.map +1 -1
  175. package/dist/index.d.ts +1 -0
  176. package/dist/index.d.ts.map +1 -1
  177. package/dist/index.js +1 -0
  178. package/dist/index.js.map +1 -1
  179. package/dist/internal/Auth.d.ts +18 -4
  180. package/dist/internal/Auth.d.ts.map +1 -1
  181. package/dist/internal/Auth.js +148 -76
  182. package/dist/internal/Auth.js.map +1 -1
  183. package/dist/internal/Cache.d.ts.map +1 -1
  184. package/dist/internal/Cache.js +4 -1
  185. package/dist/internal/Cache.js.map +1 -1
  186. package/dist/internal/EdgeCache.d.ts +10 -0
  187. package/dist/internal/EdgeCache.d.ts.map +1 -1
  188. package/dist/internal/EdgeCache.js +39 -8
  189. package/dist/internal/EdgeCache.js.map +1 -1
  190. package/dist/internal/Fees.d.ts +27 -0
  191. package/dist/internal/Fees.d.ts.map +1 -0
  192. package/dist/internal/Fees.js +48 -0
  193. package/dist/internal/Fees.js.map +1 -0
  194. package/dist/internal/FundingRoutes.d.ts +183 -0
  195. package/dist/internal/FundingRoutes.d.ts.map +1 -0
  196. package/dist/internal/FundingRoutes.js +490 -0
  197. package/dist/internal/FundingRoutes.js.map +1 -0
  198. package/dist/internal/Id.d.ts +15 -0
  199. package/dist/internal/Id.d.ts.map +1 -0
  200. package/dist/internal/Id.js +22 -0
  201. package/dist/internal/Id.js.map +1 -0
  202. package/dist/internal/Log.d.ts +2 -0
  203. package/dist/internal/Log.d.ts.map +1 -1
  204. package/dist/internal/Log.js +3 -0
  205. package/dist/internal/Log.js.map +1 -1
  206. package/dist/internal/OpenApi.d.ts +10 -1
  207. package/dist/internal/OpenApi.d.ts.map +1 -1
  208. package/dist/internal/OpenApi.js +8 -1
  209. package/dist/internal/OpenApi.js.map +1 -1
  210. package/dist/internal/RateLimit.d.ts +25 -0
  211. package/dist/internal/RateLimit.d.ts.map +1 -1
  212. package/dist/internal/RateLimit.js +51 -0
  213. package/dist/internal/RateLimit.js.map +1 -1
  214. package/dist/internal/Response.d.ts +1 -1
  215. package/dist/internal/Response.d.ts.map +1 -1
  216. package/dist/internal/Schema.d.ts +8 -0
  217. package/dist/internal/Schema.d.ts.map +1 -1
  218. package/dist/internal/Schema.js +7 -0
  219. package/dist/internal/Schema.js.map +1 -1
  220. package/dist/internal/Webhooks.d.ts.map +1 -1
  221. package/dist/internal/Webhooks.js +3 -3
  222. package/dist/internal/Webhooks.js.map +1 -1
  223. package/package.json +4 -3
  224. package/src/ApiKey.ts +9 -2
  225. package/src/ApiKeys.test.ts +51 -0
  226. package/src/ApiKeys.ts +50 -1
  227. package/src/App.test.ts +33 -86718
  228. package/src/App.ts +18 -0
  229. package/src/Client.test-d.ts +351 -0
  230. package/src/Scope.ts +16 -3
  231. package/src/admin/apps/scopes.test.ts +6 -0
  232. package/src/admin/apps/scopes.ts +3 -0
  233. package/src/admin/ui/src/components/Page.tsx +2 -3
  234. package/src/admin/ui/src/routes/__root.tsx +2 -2
  235. package/src/admin/ui/src/routes/api-keys.tsx +2 -2
  236. package/src/admin/ui.generated.ts +13 -13
  237. package/src/analytics/Analytics.test.ts +527 -0
  238. package/src/analytics/Analytics.ts +318 -0
  239. package/src/analytics/tables/requestEvents.ts +523 -0
  240. package/src/apps/data/App.test.ts +72 -0
  241. package/src/apps/data/App.ts +35 -0
  242. package/src/apps/data/routes/funding.test.ts +214 -0
  243. package/src/apps/data/routes/funding.ts +584 -0
  244. package/src/apps/data/routes/receipts.ts +2 -9
  245. package/src/apps/data/routes/rpc.test.ts +78 -2
  246. package/src/apps/data/routes/rpc.ts +31 -1
  247. package/src/apps/data/routes/webhooks.test.ts +1 -1
  248. package/src/apps/data/routes/webhooks.ts +11 -8
  249. package/src/apps/docs/App.ts +29 -100
  250. package/src/apps/docs/reference.ts +132 -0
  251. package/src/apps/index.ts +1 -0
  252. package/src/apps/mcp.test.ts +206 -0
  253. package/src/apps/mcp.ts +250 -0
  254. package/src/apps/platform/App.ts +43 -4
  255. package/src/apps/platform/Billing.test.ts +530 -0
  256. package/src/apps/platform/Billing.ts +682 -0
  257. package/src/apps/platform/index.ts +1 -0
  258. package/src/apps/platform/routes/api-keys.test.ts +172 -9
  259. package/src/apps/platform/routes/api-keys.ts +136 -20
  260. package/src/apps/platform/routes/billing.test.ts +1100 -0
  261. package/src/apps/platform/routes/billing.ts +966 -0
  262. package/src/apps/platform/routes/invitations.test.ts +55 -7
  263. package/src/apps/platform/routes/invitations.ts +68 -0
  264. package/src/apps/platform/routes/me.test.ts +230 -0
  265. package/src/apps/platform/routes/orgs.ts +26 -4
  266. package/src/apps/platform/routes/scopes.test.ts +29 -0
  267. package/src/apps/platform/routes/scopes.ts +85 -0
  268. package/src/apps/platform/routes/usage.test.ts +422 -0
  269. package/src/apps/platform/routes/usage.ts +527 -0
  270. package/src/apps/relay/App.test.ts +457 -8
  271. package/src/apps/relay/App.ts +120 -12
  272. package/src/apps/relay/Sponsorships.test.ts +1 -1
  273. package/src/apps/relay/Sponsorships.ts +2 -1
  274. package/src/cli/Cli.test.ts +25 -2
  275. package/src/cli/Cli.ts +17 -2
  276. package/src/cli/commands/admin.test.ts +83 -0
  277. package/src/cli/commands/admin.ts +89 -1
  278. package/src/db/Db.ts +137 -0
  279. package/src/db/Schema.ts +104 -2
  280. package/src/db/tables/billingSettings.test.ts +87 -0
  281. package/src/db/tables/billingSettings.ts +108 -0
  282. package/src/db/tables/invitations.ts +2 -2
  283. package/src/db/tables/organizations.test.ts +1 -1
  284. package/src/db/tables/organizations.ts +15 -5
  285. package/src/db/tables/projects.ts +2 -2
  286. package/src/db/tables/requestUsage.ts +231 -0
  287. package/src/db/tables/sponsoredTransactions.test.ts +108 -1
  288. package/src/db/tables/sponsoredTransactions.ts +230 -2
  289. package/src/db/tables/stripeCustomers.test.ts +109 -0
  290. package/src/db/tables/stripeCustomers.ts +162 -0
  291. package/src/db/tables/users.ts +2 -2
  292. package/src/handlers/internal/sponsorship.ts +34 -7
  293. package/src/handlers/internal/utils.ts +19 -7
  294. package/src/index.ts +1 -0
  295. package/src/internal/Auth.test.ts +175 -25
  296. package/src/internal/Auth.ts +175 -86
  297. package/src/internal/Cache.ts +5 -1
  298. package/src/internal/EdgeCache.test.ts +27 -4
  299. package/src/internal/EdgeCache.ts +47 -9
  300. package/src/internal/Fees.test.ts +32 -0
  301. package/src/internal/Fees.ts +51 -0
  302. package/src/internal/FundingRoutes.ts +583 -0
  303. package/src/internal/Id.test.ts +17 -0
  304. package/src/internal/Id.ts +24 -0
  305. package/src/internal/Log.test.ts +1 -0
  306. package/src/internal/Log.ts +5 -0
  307. package/src/internal/OpenApi.ts +12 -1
  308. package/src/internal/RateLimit.test.ts +84 -0
  309. package/src/internal/RateLimit.ts +70 -0
  310. package/src/internal/Response.ts +1 -1
  311. package/src/internal/Schema.ts +8 -0
  312. package/src/internal/Webhooks.test.ts +3 -3
  313. package/src/internal/Webhooks.ts +3 -3
@@ -6,6 +6,7 @@ import { createMiddleware } from 'hono/factory'
6
6
  import { matchedRoutes } from 'hono/route'
7
7
  import type { MergePath, RouterRoute, Schema as core_Schema } from 'hono/types'
8
8
  import { findTargetHandler } from 'hono/utils/handler'
9
+ import { decodeJwt } from 'jose'
9
10
  import { Credential, type Method } from 'mppx'
10
11
  import { Mppx, tempo } from 'mppx/hono'
11
12
  import * as z from 'zod/mini'
@@ -15,6 +16,7 @@ import * as ApiKey from '../ApiKey.js'
15
16
  import * as ApiKeys from '../ApiKeys.js'
16
17
  import * as Db from '../db/Db.js'
17
18
  import type * as Store from './Store.js'
19
+ import * as Invitations from '../db/tables/invitations.js'
18
20
  import * as Memberships from '../db/tables/memberships.js'
19
21
  import * as Metadata from '../apps/metadata.js'
20
22
  import * as OpenApi from './OpenApi.js'
@@ -46,6 +48,13 @@ export type Context = {
46
48
  | undefined
47
49
  /** MPP payment configuration. */
48
50
  mpp?: Mpp | undefined
51
+ /**
52
+ * Configured default anonymous public quota. Used as the throttle ceiling for
53
+ * sandbox keys without active billing on routes that expose no public lane
54
+ * (where the per-route `policy.public` is absent). Omit to fall back to the
55
+ * framework default.
56
+ */
57
+ publicRateLimit?: RateLimit.Limit | undefined
49
58
  /** Rate-limit store used by API handlers. */
50
59
  rateLimit?: RateLimit.Store | undefined
51
60
  /** Session lane resolution (the session surface's capability), or undefined when sessions are unmounted. */
@@ -465,6 +474,10 @@ export function middleware<
465
474
  resolve,
466
475
  },
467
476
  }),
477
+ // The throttle ceiling for sandbox keys without active billing, so
478
+ // API-key-only routes (no per-route `policy.public`) still fall back to the
479
+ // deployment's configured public quota.
480
+ publicRateLimit: defaults.public.rateLimit,
468
481
  ...(options.mpp === false
469
482
  ? {}
470
483
  : {
@@ -677,11 +690,19 @@ export function install(options: install.Options) {
677
690
  // mounted sub-app, and the public lane rate-limits challenge-store writes.
678
691
  const app = (() => {
679
692
  if (!options_session || !kv) return undefined
680
- const { issuer, origin, ttl } =
693
+ const { issuer, origin, requireEmail, ttl } =
681
694
  (typeof options_session === 'object' ? options_session.wallet : undefined) ?? {}
695
+ // Case-insensitive exact-match email-domain allowlist for new-user sign-ups.
696
+ const emailAllowlist = (
697
+ typeof options_session === 'object' ? options_session.emailAllowlist : undefined
698
+ )
699
+ ?.map((domain) => domain.trim().toLowerCase())
700
+ .filter(Boolean)
682
701
 
683
702
  const handler = Accounts.Handler.auth({
684
- ...(issuer ? { identity: { issuer } } : {}),
703
+ ...(issuer || requireEmail
704
+ ? { identity: { ...(issuer ? { issuer } : {}), ...(requireEmail ? { required: true } : {}) } } // prettier-ignore
705
+ : {}),
685
706
  // Sign-in upserts the `users` row keyed by address.
686
707
  onAuthenticate: async ({ address }) => {
687
708
  await Users.upsertByAddress(Db.get(db), { address })
@@ -738,7 +759,41 @@ export function install(options: install.Options) {
738
759
  }
739
760
 
740
761
  const policy_public = policy({ public: true })
762
+ const policy_session = policy({ public: true, session: true })
741
763
  const describe = OpenApi.describeRoute({ hide: true })
764
+
765
+ // Sign-up gate: new addresses need an allowlisted identity email domain
766
+ // or a pending invitation; existing users pass. The claim decodes
767
+ // unverified; the mounted handler verifies the token before any session
768
+ // or user is created, so a forged claim never authenticates.
769
+ const gate_signup = createMiddleware<App.Environment>(async (c, next) => {
770
+ if (!emailAllowlist?.length) return next()
771
+ const { address, idToken } = (await c.req.json()) as z.output<typeof schema.verify.Body>
772
+ const database = Db.get(db)
773
+ const user = await Users.getByAddress(database, address)
774
+ if (!user) {
775
+ const email = (() => {
776
+ if (!idToken) return undefined
777
+ try {
778
+ const { email } = decodeJwt<{ email?: string }>(idToken)
779
+ return typeof email === 'string' ? email.toLowerCase() : undefined
780
+ } catch {
781
+ return undefined
782
+ }
783
+ })()
784
+ const allowed = await (async () => {
785
+ if (!email) return false
786
+ const at = email.lastIndexOf('@')
787
+ if (at > 0 && emailAllowlist.includes(email.slice(at + 1))) return true
788
+ // A pending invitation admits the email regardless of domain.
789
+ return (await Invitations.listByEmail(database, email)).length > 0
790
+ })()
791
+ if (!allowed)
792
+ return c.json({ error: 'sign-ups are restricted to approved email domains or invited emails' }, 403) // prettier-ignore
793
+ }
794
+ return next()
795
+ })
796
+
742
797
  const app = new Hono<App.Environment>()
743
798
  .use('/v1/auth/siwe', policy_public, describe)
744
799
  .use('/v1/auth/siwe/*', policy_public, describe)
@@ -779,6 +834,7 @@ export function install(options: install.Options) {
779
834
  code: 'body_invalid',
780
835
  message: 'Invalid request body',
781
836
  }),
837
+ gate_signup,
782
838
  OpenApi.describeRoute({
783
839
  description:
784
840
  'Verifies the signed challenge and establishes a session. Sets a session cookie by default; pass `returnToken` to receive a bearer token instead.',
@@ -796,8 +852,11 @@ export function install(options: install.Options) {
796
852
  },
797
853
  },
798
854
  },
799
- 400: handlerError('Invalid, expired, or mismatched challenge message.'),
855
+ 400: handlerError('Invalid, expired, or mismatched challenge message; or a required identity token is missing.'), // prettier-ignore
800
856
  401: handlerError('Signature or identity verification failed.'),
857
+ ...(emailAllowlist?.length
858
+ ? { 403: handlerError('Sign-up rejected: the identity email is neither on an allowlisted domain nor pending an invitation.') } // prettier-ignore
859
+ : {}),
801
860
  409: handlerError('Challenge nonce already used or unknown.'),
802
861
  429: OpenApi.standardError(429, 'Rate limited.'),
803
862
  500: OpenApi.standardError(500, 'Internal server error.'),
@@ -808,7 +867,7 @@ export function install(options: install.Options) {
808
867
  )
809
868
  app.post(
810
869
  '/v1/auth/siwe/logout',
811
- policy_public,
870
+ policy_session,
812
871
  OpenApi.describeRoute({
813
872
  description: 'Revokes the current session and clears the session cookie.',
814
873
  hide: false,
@@ -845,8 +904,8 @@ export declare namespace install {
845
904
  type Options = {
846
905
  auth:
847
906
  | (Omit<middleware.Options, 'session'> & {
848
- /** Session sign-in surface: mounts `/v1/auth/siwe` (challenge/verify/logout) and enables the session lane. Pass true for defaults or a wallet config. Requires `kv`. */
849
- session?: boolean | { wallet?: Wallet | undefined } | undefined
907
+ /** Session sign-in surface: mounts `/v1/auth/siwe` (challenge/verify/logout) and enables the session lane. Pass true for defaults or a config object. Requires `kv`. */
908
+ session?: Session | undefined
850
909
  })
851
910
  | false
852
911
  | undefined
@@ -856,12 +915,24 @@ export declare namespace install {
856
915
  kv: { store: Store.State } | undefined
857
916
  }
858
917
 
918
+ /** Session sign-in surface configuration: `true` for defaults, or a config object. */
919
+ type Session =
920
+ | boolean
921
+ | {
922
+ /** Restrict new-user sign-ups to verified emails on these domains (case-insensitive exact match); invited emails and existing users sign in unaffected. */
923
+ emailAllowlist?: readonly string[] | undefined
924
+ /** SIWE wallet sign-in configuration. */
925
+ wallet?: Wallet | undefined
926
+ }
927
+
859
928
  /** SIWE wallet sign-in configuration. */
860
929
  type Wallet = {
861
930
  /** Identity (verified email) issuer override; defaults to the Tempo wallet's production OIDC mount. */
862
931
  issuer?: string | undefined
863
932
  /** Pinned absolute origin for SIWE domain binding. Required off-Workers; on Workers the edge proxy headers are trusted by default. */
864
933
  origin?: string | undefined
934
+ /** Reject sign-ins that carry no valid identity token, so every session holds a verified email. */
935
+ requireEmail?: boolean | undefined
865
936
  /** Session and challenge TTL overrides, in seconds. */
866
937
  ttl?: { challenge?: number | undefined; session?: number | undefined } | undefined
867
938
  }
@@ -1199,84 +1270,98 @@ export function require<environment extends Environment = Environment>(
1199
1270
  })
1200
1271
  }
1201
1272
 
1202
- // Sandbox keys are restricted to non-mainnet chains. An explicit
1203
- // `chainId` of mainnet makes the key unusable for this request, so it is
1204
- // ignored and the request falls through to the public policy below (as if
1205
- // no key were presented). A request that omits `chainId` is steered to
1206
- // testnet so a sandbox key still "just works" without passing `?chainId`.
1273
+ // Sandbox keys are testnet-only. A sandbox key that explicitly targets
1274
+ // mainnet (`?chainId`) is rejected with a clear message rather than
1275
+ // steered or downgraded to anonymous, so the caller learns their key does
1276
+ // not cover mainnet. The no-`chainId` default (mainnet) is enforced by
1277
+ // the data group's chain guard, which knows the route is chain-scoped;
1278
+ // the relay resolves its chain from the request body and enforces the
1279
+ // same rule in its sponsor validation.
1207
1280
  const explicitChain = explicitChainId(c)
1208
- const sandboxOnMainnet =
1281
+
1282
+ const principal = principalFromApiKey(apiKey)
1283
+ c.set('principal', principal)
1284
+
1285
+ if (
1209
1286
  apiKey.environment === 'sandbox' &&
1210
1287
  explicitChain !== undefined &&
1211
1288
  Viem.isMainnet(explicitChain)
1289
+ )
1290
+ return jsonError(c, {
1291
+ code: 'api_key_forbidden',
1292
+ message: 'Sandbox API keys only support testnet. Pass a testnet `chainId`.',
1293
+ status: 403,
1294
+ })
1212
1295
 
1213
- if (!sandboxOnMainnet) {
1214
- if (
1215
- apiKey.environment === 'sandbox' &&
1216
- explicitChain === undefined &&
1217
- Viem.isMainnet((c.get('chainId' as never) as number | undefined) ?? Viem.defaultChainId)
1218
- )
1219
- // `chainId` lives on the app's `Variables`, not the narrower auth
1220
- // contract, so it is read/written through an untyped cast.
1221
- c.set('chainId' as never, Viem.chainId.testnet as never)
1222
-
1223
- const principal = principalFromApiKey(apiKey)
1224
- c.set('principal', principal)
1225
- // Default-deny: routes with no API-key lane allow only the super admin.
1226
- // A valid key is authenticated but forbidden.
1227
- if (!policy.apiKey)
1228
- return jsonError(c, {
1229
- code: 'forbidden',
1230
- message: 'API key not permitted for this route',
1231
- status: 403,
1232
- })
1233
- const missing = apiKey.scopes.includes(Scope.wildcard)
1234
- ? []
1235
- : (policy.apiKey?.scopes.filter((scope) => !apiKey.scopes.includes(scope)) ?? [])
1236
- if (missing.length > 0)
1237
- return jsonError(c, {
1238
- code: 'api_key_forbidden',
1239
- message: 'API key missing required scope',
1240
- status: 403,
1241
- })
1296
+ // Default-deny: routes with no API-key lane allow only the super admin.
1297
+ // A valid key is authenticated but forbidden.
1298
+ if (!policy.apiKey)
1299
+ return jsonError(c, {
1300
+ code: 'forbidden',
1301
+ message: 'API key not permitted for this route',
1302
+ status: 403,
1303
+ })
1304
+ const missing = apiKey.scopes.includes(Scope.wildcard)
1305
+ ? []
1306
+ : (policy.apiKey?.scopes.filter((scope) => !apiKey.scopes.includes(scope)) ?? [])
1307
+ if (missing.length > 0)
1308
+ return jsonError(c, {
1309
+ code: 'api_key_forbidden',
1310
+ message: 'API key missing required scope',
1311
+ status: 403,
1312
+ })
1242
1313
 
1243
- if (hasPayment)
1244
- return requirePayment(c, next, {
1314
+ // Sandbox keys without active billing are throttled to the public quota.
1315
+ // `billingActive` is snapshotted on the key record (stamped at mint,
1316
+ // re-synced when billing changes), so this needs no billing read on the
1317
+ // request path. Resolved before the paid lane so a payment credential
1318
+ // can't buy past the throttle: sandbox is non-billable testnet.
1319
+ const throttled = apiKey.environment === 'sandbox' && apiKey.billingActive !== true
1320
+
1321
+ if (hasPayment && !throttled)
1322
+ return requirePayment(c, next, {
1323
+ auth,
1324
+ payment: { reason: 'api_key_over_quota', type: 'mpp' },
1325
+ policy,
1326
+ principal,
1327
+ })
1328
+
1329
+ if (policy.apiKey) {
1330
+ // A route consumes exactly one quota bucket: its single required scope,
1331
+ // else `'*'` (zero scopes). Routes today require at most one scope; a
1332
+ // future multi-scope route buckets against its first scope.
1333
+ const scope = policy.apiKey.scopes[0] ?? Scope.wildcard
1334
+ // A throttled sandbox key shares the key's bucket but at the public
1335
+ // ceiling, so activating billing lifts the limit on the same counter.
1336
+ const limit = throttled
1337
+ ? (policy.public?.rateLimit ?? auth.publicRateLimit ?? { perMinute: 60 })
1338
+ : resolveApiKeyLimit({ apiKey, auth, policy: policy.apiKey, scope })
1339
+ const result = await consumeRateLimit(auth, {
1340
+ key: `api_key:${apiKey.id}:scope:${scope}`,
1341
+ limit,
1342
+ })
1343
+ if (result) {
1344
+ setRateLimitHeaders(c, result)
1345
+ // Surface the bucket so callers can correlate a 429 with the exhausted
1346
+ // quota. Set before the over-quota branch so it rides the 429 (an MPP
1347
+ // 402 challenge is owned by mppx and drops these headers — by design).
1348
+ c.header('RateLimit-Scope', scope)
1349
+ }
1350
+ if (result && !result.allowed) {
1351
+ // A throttled sandbox key gets a plain 429, never an MPP challenge —
1352
+ // it must activate billing to lift the ceiling, not pay per request.
1353
+ if (throttled) return rateLimitExceeded(c, result)
1354
+ return requirePaymentOrRateLimit(c, next, {
1245
1355
  auth,
1246
1356
  payment: { reason: 'api_key_over_quota', type: 'mpp' },
1247
1357
  policy,
1248
1358
  principal,
1359
+ rateLimit: result,
1249
1360
  })
1250
-
1251
- if (policy.apiKey) {
1252
- // A route consumes exactly one quota bucket: its single required scope,
1253
- // else `'*'` (zero scopes). Routes today require at most one scope; a
1254
- // future multi-scope route buckets against its first scope.
1255
- const scope = policy.apiKey.scopes[0] ?? Scope.wildcard
1256
- const limit = resolveApiKeyLimit({ apiKey, auth, policy: policy.apiKey, scope })
1257
- const result = await consumeRateLimit(auth, {
1258
- key: `api_key:${apiKey.id}:scope:${scope}`,
1259
- limit,
1260
- })
1261
- if (result) {
1262
- setRateLimitHeaders(c, result)
1263
- // Surface the bucket so callers can correlate a 429 with the exhausted
1264
- // quota. Set before the over-quota branch so it rides the 429 (an MPP
1265
- // 402 challenge is owned by mppx and drops these headers — by design).
1266
- c.header('RateLimit-Scope', scope)
1267
- }
1268
- if (result && !result.allowed)
1269
- return requirePaymentOrRateLimit(c, next, {
1270
- auth,
1271
- payment: { reason: 'api_key_over_quota', type: 'mpp' },
1272
- policy,
1273
- principal,
1274
- rateLimit: result,
1275
- })
1276
1361
  }
1277
-
1278
- return next()
1279
1362
  }
1363
+
1364
+ return next()
1280
1365
  }
1281
1366
 
1282
1367
  // Cookie sessions carry no Authorization credential; resolve the session
@@ -1410,23 +1495,26 @@ function consumeRateLimit(
1410
1495
  // or a stall past `rateLimitConsumeTimeout` degrades to unenforced quota
1411
1496
  // instead of failing — or blocking — the request.
1412
1497
  return withTimeout(auth.rateLimit.consume(options), rateLimitConsumeTimeout).catch((error) => {
1413
- // A timeout or Cloudflare Durable Object overload is an expected
1414
- // degradation (a slow/hot counter backend), logged at warn so it stays out
1415
- // of error dashboards; an unexpected store throw is a genuine error.
1416
- if (error instanceof RateLimitTimeoutError || isDurableObjectOverload(error))
1498
+ // A timeout or transient Durable Object fault is an expected degradation,
1499
+ // logged at warn to stay out of error dashboards; an unexpected store
1500
+ // throw is a genuine error.
1501
+ if (error instanceof RateLimitTimeoutError || isDurableObjectFault(error))
1417
1502
  console.warn('[auth] rate-limit consume degraded; failing open', error)
1418
1503
  else console.error('[auth] rate-limit consume failed; failing open', error)
1419
1504
  return null
1420
1505
  })
1421
1506
  }
1422
1507
 
1423
- function isDurableObjectOverload(error: unknown): boolean {
1424
- return (
1425
- typeof error === 'object' &&
1426
- error !== null &&
1427
- 'overloaded' in error &&
1428
- (error as { overloaded?: unknown }).overloaded === true
1429
- )
1508
+ function isDurableObjectFault(error: unknown): boolean {
1509
+ const { message, overloaded, retryable } = (error ?? {}) as {
1510
+ message?: string | undefined
1511
+ overloaded?: boolean | undefined
1512
+ retryable?: boolean | undefined
1513
+ }
1514
+ if (overloaded === true || retryable === true) return true
1515
+ // Cloudflare redacts internal Durable Object faults to "internal error;
1516
+ // reference = <token>", with no marker property to detect them by.
1517
+ return typeof message === 'string' && message.startsWith('internal error')
1430
1518
  }
1431
1519
 
1432
1520
  /**
@@ -1640,11 +1728,12 @@ function requirePaymentOrRateLimit<environment extends Environment>(
1640
1728
  options: RateLimitAccess,
1641
1729
  ) {
1642
1730
  if (isPaymentEnabled(options.auth, options.policy)) return requirePayment(c, next, options)
1731
+ return rateLimitExceeded(c, options.rateLimit)
1732
+ }
1643
1733
 
1644
- c.header(
1645
- 'Retry-After',
1646
- String(Math.max(options.rateLimit.reset - Math.ceil(Date.now() / 1_000), 1)),
1647
- )
1734
+ /** Emits a `429` with a `Retry-After` derived from the window reset. */
1735
+ function rateLimitExceeded(c: hono_Context, result: RateLimit.Result) {
1736
+ c.header('Retry-After', String(Math.max(result.reset - Math.ceil(Date.now() / 1_000), 1)))
1648
1737
  return jsonError(c, {
1649
1738
  code: 'rate_limit_exceeded',
1650
1739
  message: 'Rate limit exceeded',
@@ -74,7 +74,7 @@ export function response<environment extends Env>(
74
74
  })
75
75
  const etag = hono_etag()
76
76
 
77
- return async (c, next) => {
77
+ const middleware: MiddlewareHandler<environment> = async (c, next) => {
78
78
  const values = response.preservedHeaders.map((name) => [name, c.res.headers.get(name)] as const)
79
79
 
80
80
  // Honor the standard request `Cache-Control` directives (plus the HTTP/1.0
@@ -155,6 +155,10 @@ export function response<environment extends Env>(
155
155
  if (result instanceof Response) return res
156
156
  return undefined
157
157
  }
158
+
159
+ // `markCacheable` lets the edge layer pay its store lookup only on routes
160
+ // that mount this middleware; everything else skips the read.
161
+ return EdgeCache.markCacheable(middleware)
158
162
  }
159
163
 
160
164
  export declare namespace response {
@@ -1,4 +1,5 @@
1
1
  import { Hono } from 'hono'
2
+ import { createMiddleware } from 'hono/factory'
2
3
  import * as EdgeCache from './EdgeCache.js'
3
4
  import type * as Store from './Store.js'
4
5
 
@@ -128,6 +129,18 @@ describe('middleware', () => {
128
129
  expect(store.size).toMatchInlineSnapshot(`0`)
129
130
  })
130
131
 
132
+ test('skips the store lookup for routes that did not opt in', async () => {
133
+ const { app, store } = createApp()
134
+
135
+ await app.request('/uncached')
136
+ await app.request('/missing-route')
137
+ expect(store.gets).toMatchInlineSnapshot(`0`)
138
+
139
+ // Marked routes still pay (and use) the lookup.
140
+ await app.request('/cached')
141
+ expect(store.gets).toMatchInlineSnapshot(`1`)
142
+ })
143
+
131
144
  test('answers conditional requests from a cached ETag with 304', async () => {
132
145
  const { app } = createApp()
133
146
 
@@ -172,13 +185,18 @@ type Environment = { Variables: EdgeCache.Variables }
172
185
  // In-memory `Store.Store` so the edge cache and origin cache share one shape.
173
186
  function memoryStore() {
174
187
  const map = new Map<string, string>()
188
+ let gets = 0
175
189
  return {
176
190
  async delete(key: string) {
177
191
  map.delete(key)
178
192
  },
179
193
  async get(key: string) {
194
+ gets++
180
195
  return map.get(key) ?? null
181
196
  },
197
+ get gets() {
198
+ return gets
199
+ },
182
200
  async list() {
183
201
  return { keys: [...map.keys()].map((name) => ({ name })) }
184
202
  },
@@ -189,7 +207,7 @@ function memoryStore() {
189
207
  return map.size
190
208
  },
191
209
  type: 'state',
192
- } satisfies Store.Store & { readonly size: number }
210
+ } satisfies Store.Store & { readonly gets: number; readonly size: number }
193
211
  }
194
212
 
195
213
  function createApp(options: { store?: Store.Store | undefined } = {}) {
@@ -203,7 +221,12 @@ function createApp(options: { store?: Store.Store | undefined } = {}) {
203
221
  downstream++
204
222
  await next()
205
223
  })
206
- app.get('/cached', (c) => {
224
+ // Routes publishing a policy mount a marked middleware, as `Cache.response`
225
+ // routes do; unmarked routes must skip the lookup entirely.
226
+ const cacheable = EdgeCache.markCacheable(
227
+ createMiddleware<Environment>(async (_c, next) => next()),
228
+ )
229
+ app.get('/cached', cacheable, (c) => {
207
230
  c.set('edgeCache', { maxAge: 30, staleWhileRevalidate: 300 })
208
231
  c.header('Age', '123')
209
232
  c.header('CF-Cache-Status', 'HIT')
@@ -212,11 +235,11 @@ function createApp(options: { store?: Store.Store | undefined } = {}) {
212
235
  c.header('RateLimit-Remaining', '9')
213
236
  return c.json({ ok: true })
214
237
  })
215
- app.get('/error', (c) => {
238
+ app.get('/error', cacheable, (c) => {
216
239
  c.set('edgeCache', { maxAge: 30, staleWhileRevalidate: 300 })
217
240
  return c.json({ ok: false }, 500)
218
241
  })
219
- app.get('/binary', (c) => {
242
+ app.get('/binary', cacheable, (c) => {
220
243
  c.set('edgeCache', { maxAge: 30, staleWhileRevalidate: 300 })
221
244
  // Bytes that are not valid UTF-8 (PNG signature) — corrupted if stored via
222
245
  // `.text()` instead of base64.
@@ -1,4 +1,5 @@
1
- import type { Env, MiddlewareHandler } from 'hono'
1
+ import type { Context, Env, MiddlewareHandler } from 'hono'
2
+ import { matchedRoutes } from 'hono/route'
2
3
  import { Base64 } from 'ox'
3
4
 
4
5
  import type * as Store from './Store.js'
@@ -53,6 +54,10 @@ const volatileHeaders = [
53
54
  * is that cache hits bypass origin metering: they are not counted against
54
55
  * rate-limit quotas.
55
56
  *
57
+ * Lookups are equally scoped: only requests whose matched route carries the
58
+ * {@link markCacheable} marker (applied by `Cache.response`) pay the store
59
+ * read, so GETs to non-cacheable routes skip the round trip entirely.
60
+ *
56
61
  * The cache is a plain `Store.Store` — the same store the origin read cache uses
57
62
  * — holding the response serialized to a string under a URL key. It is a no-op
58
63
  * when no store is supplied.
@@ -68,8 +73,14 @@ export function middleware<environment extends Env>(
68
73
  ): MiddlewareHandler<environment> {
69
74
  return async (c, next) => {
70
75
  const { store } = options
76
+ // Pay the store lookup only when a matched handler is marked cacheable
77
+ // (mounted `Cache.response`); other GETs (authed platform routes, docs,
78
+ // 404s) can never hit, so the read would be a wasted round trip.
71
79
  const eligible =
72
- store !== undefined && c.req.method === 'GET' && !c.req.raw.headers.has('range')
80
+ store !== undefined &&
81
+ c.req.method === 'GET' &&
82
+ !c.req.raw.headers.has('range') &&
83
+ isCacheableRoute(c)
73
84
  const key = eligible ? cacheKey(c.req.url) : undefined
74
85
 
75
86
  // Honor the standard request `Cache-Control` directives so a caller can
@@ -104,16 +115,41 @@ export function middleware<environment extends Env>(
104
115
  if (c.res.headers.get('content-type')?.includes('text/event-stream')) return
105
116
 
106
117
  // Store a public, sanitized copy; the client still receives the original
107
- // (private, metered) response for this miss.
108
- const value = await serialize(c.res, policy)
109
- const put = store.put(key, value, { ttl: policy.maxAge * 1_000 })
118
+ // (private, metered) response for this miss. Clone synchronously — the
119
+ // original body locks once it streams to the client — then serialize and
120
+ // write behind `waitUntil` so the miss response is not held open on the
121
+ // body read.
122
+ const copy = c.res.clone()
123
+ const persist = serialize(copy, policy).then((value) =>
124
+ store.put(key, value, { ttl: policy.maxAge * 1_000 }),
125
+ )
110
126
  const waitUntil = getWaitUntil(c)
111
- if (waitUntil) waitUntil(put)
112
- else await put.catch(() => {})
127
+ if (waitUntil) waitUntil(persist)
128
+ else await persist.catch(() => {})
113
129
  return
114
130
  }
115
131
  }
116
132
 
133
+ // Marks handlers whose route publishes an edge-cache policy; the middleware
134
+ // pays the store lookup only when a matched handler carries it.
135
+ const cacheableSymbol = Symbol('tempoApi.edgeCacheable')
136
+
137
+ /**
138
+ * Marks a route handler/middleware as publishing an edge-cache policy, so
139
+ * {@link middleware} looks its route up in the edge cache. `Cache.response`
140
+ * applies it; routes composed any other way never enter the shared cache.
141
+ */
142
+ export function markCacheable<const handler extends (...args: never[]) => unknown>(
143
+ handler: handler,
144
+ ): handler {
145
+ return Object.assign(handler, { [cacheableSymbol]: true })
146
+ }
147
+
148
+ // True when any handler matched for this request is marked cacheable.
149
+ function isCacheableRoute(c: Context): boolean {
150
+ return matchedRoutes(c).some((route) => cacheableSymbol in route.handler)
151
+ }
152
+
117
153
  export declare namespace middleware {
118
154
  /** Options for the edge-cache middleware. */
119
155
  type Options = {
@@ -134,6 +170,8 @@ type Stored = {
134
170
  status: number
135
171
  }
136
172
 
173
+ // Consumes `response` (callers pass a dedicated clone; the client streams the
174
+ // original).
137
175
  async function serialize(response: Response, policy: Policy): Promise<string> {
138
176
  const headers = new Headers(response.headers)
139
177
  for (const name of volatileHeaders) headers.delete(name)
@@ -145,8 +183,8 @@ async function serialize(response: Response, policy: Policy): Promise<string> {
145
183
  const textual = isTextual(headers.get('content-type'))
146
184
  const stored: Stored = {
147
185
  body: textual
148
- ? await response.clone().text()
149
- : Base64.fromBytes(new Uint8Array(await response.clone().arrayBuffer())),
186
+ ? await response.text()
187
+ : Base64.fromBytes(new Uint8Array(await response.arrayBuffer())),
150
188
  ...(textual ? {} : { encoding: 'base64' as const }),
151
189
  headers: [...headers],
152
190
  status: response.status,
@@ -0,0 +1,32 @@
1
+ import * as Fees from './Fees.js'
2
+
3
+ describe('fromGas', () => {
4
+ test('behavior: scales attodollar gas math to token units with ceiling division', () => {
5
+ // 100k gas × 1e6 atto = 1e11 atto rounds up to one token unit.
6
+ expect(Fees.fromGas(100_000n, 1_000_000n)).toBe(1n)
7
+ // 1M gas × 5e11 atto = 5e17 atto = $0.50 exactly.
8
+ expect(Fees.fromGas(1_000_000n, 500_000_000_000n)).toBe(500_000n)
9
+ // Any remainder rounds up, mirroring `calc_gas_balance_spending`.
10
+ expect(Fees.fromGas(1n, 1_000_000_000_001n)).toBe(2n)
11
+ expect(Fees.fromGas(0n, 0n)).toBe(0n)
12
+ })
13
+ })
14
+
15
+ describe('maxOf', () => {
16
+ test('behavior: computes the signed cap from bigint or hex quantity fields', () => {
17
+ expect(Fees.maxOf({ gas: 1_000_000n, maxFeePerGas: 500_000_000_000n })).toBe(500_000n)
18
+ // Fills carry hex quantities: 0xf4240 = 1M gas, 0x746a528800 = 5e11 atto.
19
+ expect(Fees.maxOf({ gas: '0xf4240', maxFeePerGas: '0x746a528800' })).toBe(500_000n)
20
+ // Zero fields are computable (a zero cap), not absent.
21
+ expect(Fees.maxOf({ gas: 0n, maxFeePerGas: 0n })).toBe(0n)
22
+ })
23
+
24
+ test('behavior: yields undefined when either fee field is missing or unparseable', () => {
25
+ expect(Fees.maxOf({})).toBeUndefined()
26
+ expect(Fees.maxOf({ gas: 21_000n })).toBeUndefined()
27
+ expect(Fees.maxOf({ maxFeePerGas: 1_000_000n })).toBeUndefined()
28
+ // Decimal strings are not hex quantities; callers fail closed.
29
+ expect(Fees.maxOf({ gas: '21000', maxFeePerGas: 1_000_000n })).toBeUndefined()
30
+ expect(Fees.maxOf({ gas: null, maxFeePerGas: 1_000_000n })).toBeUndefined()
31
+ })
32
+ })