tapimo 0.13.0 → 0.14.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 (230) hide show
  1. package/dist/App.d.ts +6064 -4829
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +1 -0
  4. package/dist/App.js.map +1 -1
  5. package/dist/Webhooks.js +2 -2
  6. package/dist/Webhooks.js.map +1 -1
  7. package/dist/admin/App.d.ts +1008 -917
  8. package/dist/admin/App.d.ts.map +1 -1
  9. package/dist/admin/apps/earn-vaults.d.ts +48 -0
  10. package/dist/admin/apps/earn-vaults.d.ts.map +1 -1
  11. package/dist/admin/apps/earn-vaults.js +2 -1
  12. package/dist/admin/apps/earn-vaults.js.map +1 -1
  13. package/dist/admin/apps/organizations.d.ts +92 -3
  14. package/dist/admin/apps/organizations.d.ts.map +1 -1
  15. package/dist/admin/apps/organizations.js +69 -7
  16. package/dist/admin/apps/organizations.js.map +1 -1
  17. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  18. package/dist/admin/ui.generated.d.ts +1 -1
  19. package/dist/admin/ui.generated.js +69 -69
  20. package/dist/admin/ui.generated.js.map +1 -1
  21. package/dist/apps/Zones.d.ts +12 -12
  22. package/dist/apps/Zones.js +3 -3
  23. package/dist/apps/data/App.d.ts +2909 -2523
  24. package/dist/apps/data/App.d.ts.map +1 -1
  25. package/dist/apps/data/App.js +11 -6
  26. package/dist/apps/data/App.js.map +1 -1
  27. package/dist/apps/data/routes/addresses.d.ts +22 -22
  28. package/dist/apps/data/routes/earn.d.ts +183 -7
  29. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  30. package/dist/apps/data/routes/earn.js +434 -75
  31. package/dist/apps/data/routes/earn.js.map +1 -1
  32. package/dist/apps/data/routes/exchanges.d.ts +15 -0
  33. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  34. package/dist/apps/data/routes/exchanges.js +180 -114
  35. package/dist/apps/data/routes/exchanges.js.map +1 -1
  36. package/dist/apps/data/routes/fee-amm.d.ts.map +1 -1
  37. package/dist/apps/data/routes/fee-amm.js +18 -19
  38. package/dist/apps/data/routes/fee-amm.js.map +1 -1
  39. package/dist/apps/data/routes/tokens.d.ts +93 -93
  40. package/dist/apps/data/routes/transfers.d.ts +12 -12
  41. package/dist/apps/data/routes/valuation.d.ts +30 -30
  42. package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
  43. package/dist/apps/data/routes/webhooks.d.ts +805 -102
  44. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  45. package/dist/apps/data/routes/webhooks.js +47 -5
  46. package/dist/apps/data/routes/webhooks.js.map +1 -1
  47. package/dist/apps/funding/App.d.ts +456 -45
  48. package/dist/apps/funding/App.d.ts.map +1 -1
  49. package/dist/apps/funding/App.js +1 -1
  50. package/dist/apps/funding/App.js.map +1 -1
  51. package/dist/apps/funding/routes/deposit-addresses.d.ts +17 -17
  52. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  53. package/dist/apps/funding/routes/deposit-addresses.js +1 -1
  54. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  55. package/dist/apps/funding/routes/transfers.d.ts +654 -73
  56. package/dist/apps/funding/routes/transfers.d.ts.map +1 -1
  57. package/dist/apps/funding/routes/transfers.js +211 -5
  58. package/dist/apps/funding/routes/transfers.js.map +1 -1
  59. package/dist/apps/management/App.d.ts +831 -387
  60. package/dist/apps/management/App.d.ts.map +1 -1
  61. package/dist/apps/management/Billing.d.ts +1 -1
  62. package/dist/apps/management/Billing.d.ts.map +1 -1
  63. package/dist/apps/management/Billing.js +17 -1
  64. package/dist/apps/management/Billing.js.map +1 -1
  65. package/dist/apps/management/routes/api-keys.d.ts +76 -76
  66. package/dist/apps/management/routes/billing.d.ts +76 -76
  67. package/dist/apps/management/routes/faucet.d.ts +12 -12
  68. package/dist/apps/management/routes/invitations.d.ts +24 -24
  69. package/dist/apps/management/routes/invite-links.d.ts +46 -46
  70. package/dist/apps/management/routes/members.d.ts +46 -46
  71. package/dist/apps/management/routes/orgs.d.ts +20 -20
  72. package/dist/apps/management/routes/projects.d.ts +44 -44
  73. package/dist/apps/management/routes/usage.d.ts +32 -32
  74. package/dist/apps/management/routes/verified-token-requests.d.ts +16 -16
  75. package/dist/apps/management/routes/webhooks.d.ts +707 -57
  76. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  77. package/dist/apps/management/routes/webhooks.js +21 -21
  78. package/dist/apps/management/routes/webhooks.js.map +1 -1
  79. package/dist/apps/relay/App.d.ts.map +1 -1
  80. package/dist/apps/relay/App.js +115 -23
  81. package/dist/apps/relay/App.js.map +1 -1
  82. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  83. package/dist/apps/relay/Sponsorships.js +27 -1
  84. package/dist/apps/relay/Sponsorships.js.map +1 -1
  85. package/dist/db/Db.d.ts.map +1 -1
  86. package/dist/db/Db.js +22 -0
  87. package/dist/db/Db.js.map +1 -1
  88. package/dist/db/Schema.d.ts +8 -3
  89. package/dist/db/Schema.d.ts.map +1 -1
  90. package/dist/db/Schema.js +16 -1
  91. package/dist/db/Schema.js.map +1 -1
  92. package/dist/db/tables/fundingTransferTransactions.d.ts +38 -0
  93. package/dist/db/tables/fundingTransferTransactions.d.ts.map +1 -1
  94. package/dist/db/tables/fundingTransferTransactions.js +44 -3
  95. package/dist/db/tables/fundingTransferTransactions.js.map +1 -1
  96. package/dist/db/tables/fundingTransfers.d.ts +17 -0
  97. package/dist/db/tables/fundingTransfers.d.ts.map +1 -1
  98. package/dist/db/tables/fundingTransfers.js +21 -0
  99. package/dist/db/tables/fundingTransfers.js.map +1 -1
  100. package/dist/db/tables/organizations.d.ts +24 -0
  101. package/dist/db/tables/organizations.d.ts.map +1 -1
  102. package/dist/db/tables/organizations.js +27 -0
  103. package/dist/db/tables/organizations.js.map +1 -1
  104. package/dist/db/tables/projects.d.ts +25 -1
  105. package/dist/db/tables/projects.d.ts.map +1 -1
  106. package/dist/db/tables/projects.js +26 -0
  107. package/dist/db/tables/projects.js.map +1 -1
  108. package/dist/db/tables/sponsoredTransactions.d.ts +44 -0
  109. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  110. package/dist/db/tables/sponsoredTransactions.js +146 -13
  111. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  112. package/dist/db/tables/webhookSubscriptions.d.ts +10 -6
  113. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  114. package/dist/db/tables/webhookSubscriptions.js +9 -6
  115. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  116. package/dist/internal/EarnVaults.js +5 -5
  117. package/dist/internal/EarnVaults.js.map +1 -1
  118. package/dist/internal/Log.d.ts +1 -1
  119. package/dist/internal/Log.d.ts.map +1 -1
  120. package/dist/internal/Log.js.map +1 -1
  121. package/dist/internal/Viem.d.ts +16 -1
  122. package/dist/internal/Viem.d.ts.map +1 -1
  123. package/dist/internal/Viem.js +12 -2
  124. package/dist/internal/Viem.js.map +1 -1
  125. package/dist/internal/funding/DepositWebhook.d.ts.map +1 -1
  126. package/dist/internal/funding/DepositWebhook.js +2 -1
  127. package/dist/internal/funding/DepositWebhook.js.map +1 -1
  128. package/dist/internal/funding/Evidence.d.ts.map +1 -1
  129. package/dist/internal/funding/Evidence.js +2 -10
  130. package/dist/internal/funding/Evidence.js.map +1 -1
  131. package/dist/internal/funding/Provider.d.ts +40 -0
  132. package/dist/internal/funding/Provider.d.ts.map +1 -1
  133. package/dist/internal/funding/Provider.js +5 -0
  134. package/dist/internal/funding/Provider.js.map +1 -1
  135. package/dist/internal/funding/SourceObservation.d.ts.map +1 -1
  136. package/dist/internal/funding/SourceObservation.js +5 -7
  137. package/dist/internal/funding/SourceObservation.js.map +1 -1
  138. package/dist/internal/funding/Transfer.d.ts +101 -6
  139. package/dist/internal/funding/Transfer.d.ts.map +1 -1
  140. package/dist/internal/funding/Transfer.js +162 -34
  141. package/dist/internal/funding/Transfer.js.map +1 -1
  142. package/dist/internal/funding/TransferReconciliation.d.ts +52 -0
  143. package/dist/internal/funding/TransferReconciliation.d.ts.map +1 -0
  144. package/dist/internal/funding/TransferReconciliation.js +192 -0
  145. package/dist/internal/funding/TransferReconciliation.js.map +1 -0
  146. package/dist/internal/funding/TransferWebhook.d.ts +21 -0
  147. package/dist/internal/funding/TransferWebhook.d.ts.map +1 -0
  148. package/dist/internal/funding/TransferWebhook.js +43 -0
  149. package/dist/internal/funding/TransferWebhook.js.map +1 -0
  150. package/dist/internal/funding/index.d.ts +1 -0
  151. package/dist/internal/funding/index.d.ts.map +1 -1
  152. package/dist/internal/funding/index.js +1 -0
  153. package/dist/internal/funding/index.js.map +1 -1
  154. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  155. package/dist/internal/funding/providers/relay.js +3 -12
  156. package/dist/internal/funding/providers/relay.js.map +1 -1
  157. package/dist/internal/funding/providers/stargate.d.ts +99 -0
  158. package/dist/internal/funding/providers/stargate.d.ts.map +1 -1
  159. package/dist/internal/funding/providers/stargate.js +337 -35
  160. package/dist/internal/funding/providers/stargate.js.map +1 -1
  161. package/dist/internal/webhookDestinations/slack.d.ts.map +1 -1
  162. package/dist/internal/webhookDestinations/slack.js +16 -0
  163. package/dist/internal/webhookDestinations/slack.js.map +1 -1
  164. package/package.json +1 -1
  165. package/src/App.ts +1 -0
  166. package/src/Client.test-d.ts +83 -2
  167. package/src/Webhooks.ts +2 -2
  168. package/src/admin/apps/earn-vaults.ts +2 -1
  169. package/src/admin/apps/organizations.test.ts +53 -1
  170. package/src/admin/apps/organizations.ts +90 -5
  171. package/src/admin/ui/src/lib/organizations.test.ts +112 -1
  172. package/src/admin/ui/src/lib/organizations.ts +55 -0
  173. package/src/admin/ui/src/routes/organizations_.$orgId.tsx +116 -0
  174. package/src/admin/ui.generated.ts +69 -69
  175. package/src/apps/Zones.test.ts +3 -3
  176. package/src/apps/Zones.ts +3 -3
  177. package/src/apps/composition.test.ts +1 -0
  178. package/src/apps/data/App.ts +15 -6
  179. package/src/apps/data/routes/earn.test.ts +369 -14
  180. package/src/apps/data/routes/earn.ts +548 -79
  181. package/src/apps/data/routes/exchanges.test.ts +740 -15
  182. package/src/apps/data/routes/exchanges.ts +954 -753
  183. package/src/apps/data/routes/fee-amm.ts +17 -18
  184. package/src/apps/data/routes/webhooks.test.ts +21 -5
  185. package/src/apps/data/routes/webhooks.ts +63 -8
  186. package/src/apps/funding/App.ts +12 -2
  187. package/src/apps/funding/routes/deposit-addresses.test.ts +4 -4
  188. package/src/apps/funding/routes/deposit-addresses.ts +2 -2
  189. package/src/apps/funding/routes/transfers.test.ts +510 -19
  190. package/src/apps/funding/routes/transfers.ts +271 -5
  191. package/src/apps/management/Billing.ts +18 -5
  192. package/src/apps/management/routes/webhooks.test.ts +3 -3
  193. package/src/apps/management/routes/webhooks.ts +24 -25
  194. package/src/apps/relay/App.test.ts +212 -1
  195. package/src/apps/relay/App.ts +158 -29
  196. package/src/apps/relay/Sponsorships.ts +24 -1
  197. package/src/db/Db.ts +35 -0
  198. package/src/db/Schema.ts +17 -2
  199. package/src/db/tables/fundingTransferTransactions.test.ts +43 -0
  200. package/src/db/tables/fundingTransferTransactions.ts +79 -3
  201. package/src/db/tables/fundingTransfers.test.ts +26 -0
  202. package/src/db/tables/fundingTransfers.ts +36 -0
  203. package/src/db/tables/organizations.test.ts +41 -0
  204. package/src/db/tables/organizations.ts +49 -0
  205. package/src/db/tables/projects.test.ts +55 -0
  206. package/src/db/tables/projects.ts +46 -1
  207. package/src/db/tables/sponsoredTransactions.test.ts +338 -0
  208. package/src/db/tables/sponsoredTransactions.ts +197 -13
  209. package/src/db/tables/webhookSubscriptions.ts +18 -11
  210. package/src/internal/EarnVaults.ts +5 -5
  211. package/src/internal/Log.test-d.ts +2 -0
  212. package/src/internal/Log.ts +2 -0
  213. package/src/internal/Viem.ts +39 -3
  214. package/src/internal/Webhooks.test.ts +45 -0
  215. package/src/internal/funding/DepositWebhook.ts +2 -1
  216. package/src/internal/funding/Evidence.ts +2 -13
  217. package/src/internal/funding/Provider.ts +43 -0
  218. package/src/internal/funding/SourceObservation.ts +5 -9
  219. package/src/internal/funding/Transfer.test.ts +155 -0
  220. package/src/internal/funding/Transfer.ts +254 -37
  221. package/src/internal/funding/TransferReconciliation.test.ts +147 -0
  222. package/src/internal/funding/TransferReconciliation.ts +264 -0
  223. package/src/internal/funding/TransferWebhook.test.ts +128 -0
  224. package/src/internal/funding/TransferWebhook.ts +67 -0
  225. package/src/internal/funding/index.test.ts +1 -0
  226. package/src/internal/funding/index.ts +1 -0
  227. package/src/internal/funding/providers/relay.ts +3 -15
  228. package/src/internal/funding/providers/stargate.test.ts +169 -4
  229. package/src/internal/funding/providers/stargate.ts +498 -51
  230. package/src/internal/webhookDestinations/slack.ts +15 -0
@@ -18,6 +18,7 @@ import type * as Db from '../../db/Db.js'
18
18
  import * as Fees from '../../internal/Fees.js'
19
19
  import type * as Log from '../../internal/Log.js'
20
20
  import * as Organizations from '../../db/tables/organizations.js'
21
+ import * as Projects from '../../db/tables/projects.js'
21
22
  import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js'
22
23
  import * as StripeCustomers from '../../db/tables/stripeCustomers.js'
23
24
  import * as core_Billing from '../management/Billing.js'
@@ -104,6 +105,13 @@ const keys = [
104
105
  scopes: ['rpc-relay:sponsor'],
105
106
  token: 'secret_unattributed',
106
107
  },
108
+ {
109
+ id: 'key_unattributed_prod',
110
+ orgId: 'org_1',
111
+ environment: 'production',
112
+ scopes: ['rpc-relay:sponsor'],
113
+ token: 'secret_unattributed_prod',
114
+ },
107
115
  ] as const satisfies readonly TestApp.kvStore.Key[]
108
116
 
109
117
  describe('behavior: Zone chain routing', () => {
@@ -525,7 +533,7 @@ describe.skipIf(runtime.mode !== 'localnet')('behavior: sponsorship', () => {
525
533
  const body = await relayRequest(app, {
526
534
  method: 'eth_signRawTransaction',
527
535
  params: [await signSponsorable()],
528
- token: 'secret_unattributed',
536
+ token: 'secret_unattributed_prod',
529
537
  })
530
538
  expect(body.error).toBeUndefined()
531
539
  expect(Transaction.deserialize(body.result as `0x76${string}`).feePayerSignature).toBeDefined()
@@ -535,6 +543,28 @@ describe.skipIf(runtime.mode !== 'localnet')('behavior: sponsorship', () => {
535
543
  expect(row?.projectId).toBeNull()
536
544
  })
537
545
 
546
+ test('behavior: organization-attributed keys use a validated request project', async () => {
547
+ const db = TestApp.database()
548
+ await Organizations.create(db, { id: 'org_1', name: 'One' })
549
+ const project = await Projects.create(db, { name: 'Privy app', orgId: 'org_1' })
550
+ const app = TestApp.create({
551
+ auth: { keys },
552
+ db,
553
+ relay: { feePayer: { account: feePayerAccount } },
554
+ })
555
+
556
+ const body = await relayRequest(app, {
557
+ method: 'eth_signRawTransaction',
558
+ params: [await signSponsorable()],
559
+ projectId: project.id,
560
+ token: 'secret_unattributed_prod',
561
+ })
562
+ expect(body.error).toBeUndefined()
563
+
564
+ const [row] = await SponsoredTransactions.listPending(db)
565
+ expect(row?.projectId).toBe(project.id)
566
+ })
567
+
538
568
  test('behavior: batches aggregate sponsorship rejections without retaining payloads', async () => {
539
569
  const db = TestApp.database()
540
570
  const entries: Log.Entry[] = []
@@ -948,6 +978,18 @@ describe('behavior: mainnet billing gate', () => {
948
978
  await TestApp.verifiedSeed(db, Viem.chainId.mainnet)
949
979
  }
950
980
 
981
+ /** Configures one attributed project for Tempo-funded sponsorship. */
982
+ async function configureSubsidy(db: ReturnType<typeof TestApp.database>) {
983
+ await Organizations.create(db, { id: 'org_1', name: 'Privy' })
984
+ await Organizations.setSponsorshipSubsidy(db, 'org_1', {
985
+ durationDays: 90,
986
+ projectSpendLimit: '100.00',
987
+ })
988
+ const project = await Projects.create(db, { name: 'Privy app', orgId: 'org_1' })
989
+ await activate(db)
990
+ return project
991
+ }
992
+
951
993
  // Gas prices are attodollars (1e-18 USD) per gas; the signed cap in
952
994
  // fee-token units is ceil(gas × maxFeePerGas / 1e12). The helper's default
953
995
  // envelope (100k gas × 1e6 atto) caps at 1 microdollar — negligible.
@@ -956,6 +998,172 @@ describe('behavior: mainnet billing gate', () => {
956
998
  /** A $2 signed cap, over the `'1.00'` platform default. */
957
999
  const overCapFees = { gas: 1_000_000n, maxFeePerGas: 2_000_000_000_000n } as const
958
1000
 
1001
+ test('behavior: active project promotions waive charges with active billing', async () => {
1002
+ const db = TestApp.database()
1003
+ const project = await configureSubsidy(db)
1004
+ const body = await relayRequest(createMainnetApp({ db }), {
1005
+ method: 'eth_signRawTransaction',
1006
+ params: [await signSponsorable({ chainId: Viem.chainId.mainnet })],
1007
+ projectId: project.id,
1008
+ token: 'secret_unattributed_prod',
1009
+ })
1010
+ expect(body.error).toBeUndefined()
1011
+
1012
+ const [row] = await SponsoredTransactions.listPending(db)
1013
+ expect(row).toMatchObject({
1014
+ billable: false,
1015
+ orgId: 'org_1',
1016
+ projectId: project.id,
1017
+ })
1018
+ const updated = await Projects.get(db, project.id)
1019
+ const startsAt = updated?.sponsorshipSubsidyStartsAt
1020
+ const endsAt = updated?.sponsorshipSubsidyEndsAt
1021
+ expect({ endsAt, startsAt }).toMatchObject({ endsAt: expect.any(String), startsAt: expect.any(String) }) // prettier-ignore
1022
+ if (!endsAt || !startsAt) throw new Error('Expected an activated promotion window.')
1023
+ expect(Date.parse(endsAt) - Date.parse(startsAt)).toBe(90 * 86_400_000)
1024
+ })
1025
+
1026
+ test('behavior: exhausted project promotions return to customer billing', async () => {
1027
+ const db = TestApp.database()
1028
+ await Organizations.create(db, { id: 'org_1', name: 'Privy' })
1029
+ await Organizations.setSponsorshipSubsidy(db, 'org_1', {
1030
+ durationDays: 90,
1031
+ projectSpendLimit: '0.000001',
1032
+ })
1033
+ const project = await Projects.create(db, { name: 'Privy app', orgId: 'org_1' })
1034
+ await activate(db)
1035
+ const app = createMainnetApp({ db })
1036
+
1037
+ const subsidized = await relayRequest(app, {
1038
+ method: 'eth_signRawTransaction',
1039
+ params: [await signSponsorable({ chainId: Viem.chainId.mainnet })],
1040
+ projectId: project.id,
1041
+ token: 'secret_unattributed_prod',
1042
+ })
1043
+ const billed = await relayRequest(app, {
1044
+ method: 'eth_signRawTransaction',
1045
+ params: [await signSponsorable({ chainId: Viem.chainId.mainnet, nonce: 1 })],
1046
+ projectId: project.id,
1047
+ token: 'secret_unattributed_prod',
1048
+ })
1049
+
1050
+ expect({ billed: billed.error, subsidized: subsidized.error }).toStrictEqual({
1051
+ billed: undefined,
1052
+ subsidized: undefined,
1053
+ })
1054
+ expect(await SponsoredTransactions.listPending(db)).toMatchObject([
1055
+ { billable: false, projectId: project.id },
1056
+ { billable: true, projectId: project.id },
1057
+ ])
1058
+ })
1059
+
1060
+ test('behavior: uncapped project promotions waive charges with active billing', async () => {
1061
+ const db = TestApp.database()
1062
+ await Organizations.create(db, { id: 'org_1', name: 'Privy' })
1063
+ await Organizations.setSponsorshipSubsidy(db, 'org_1', {
1064
+ durationDays: 90,
1065
+ projectSpendLimit: null,
1066
+ })
1067
+ const project = await Projects.create(db, { name: 'Privy app', orgId: 'org_1' })
1068
+ await activate(db)
1069
+ const body = await relayRequest(createMainnetApp({ db }), {
1070
+ method: 'eth_signRawTransaction',
1071
+ params: [await signSponsorable({ chainId: Viem.chainId.mainnet })],
1072
+ projectId: project.id,
1073
+ token: 'secret_unattributed_prod',
1074
+ })
1075
+ expect(body.error).toBeUndefined()
1076
+ expect(await SponsoredTransactions.listPending(db)).toMatchObject([
1077
+ { billable: false, projectId: project.id },
1078
+ ])
1079
+ })
1080
+
1081
+ test('behavior: project promotions still require active billing', async () => {
1082
+ const db = TestApp.database()
1083
+ await Organizations.create(db, { id: 'org_1', name: 'Privy' })
1084
+ await Organizations.setSponsorshipSubsidy(db, 'org_1', {
1085
+ durationDays: 90,
1086
+ projectSpendLimit: null,
1087
+ })
1088
+ const project = await Projects.create(db, { name: 'Privy app', orgId: 'org_1' })
1089
+ await TestApp.verifiedSeed(db, Viem.chainId.mainnet)
1090
+ const body = await relayRequest(createMainnetApp({ db }), {
1091
+ method: 'eth_signRawTransaction',
1092
+ params: [await signSponsorable({ chainId: Viem.chainId.mainnet })],
1093
+ projectId: project.id,
1094
+ token: 'secret_unattributed_prod',
1095
+ })
1096
+ expect(body.error?.data).toStrictEqual({ code: 'billing_required' })
1097
+ expect(await SponsoredTransactions.listPending(db)).toHaveLength(0)
1098
+ expect(await Projects.get(db, project.id)).toMatchObject({
1099
+ sponsorshipSubsidyEndsAt: null,
1100
+ sponsorshipSubsidyStartsAt: null,
1101
+ })
1102
+ })
1103
+
1104
+ test('behavior: subsidized organization keys without project attribution use customer billing', async () => {
1105
+ const db = TestApp.database()
1106
+ await configureSubsidy(db)
1107
+ const body = await relayRequest(createMainnetApp({ db }), {
1108
+ method: 'eth_signRawTransaction',
1109
+ params: [await signSponsorable({ chainId: Viem.chainId.mainnet })],
1110
+ token: 'secret_unattributed_prod',
1111
+ })
1112
+ expect(body.error).toBeUndefined()
1113
+ expect(await SponsoredTransactions.listPending(db)).toMatchObject([
1114
+ { billable: true, orgId: 'org_1', projectId: null },
1115
+ ])
1116
+ })
1117
+
1118
+ test('behavior: expired project promotions return to customer billing', async () => {
1119
+ const db = TestApp.database()
1120
+ const project = await configureSubsidy(db)
1121
+ await Projects.activateSponsorship(db, {
1122
+ at: new Date(Date.now() - 91 * 86_400_000).toISOString(),
1123
+ endsAt: new Date(Date.now() - 86_400_000).toISOString(),
1124
+ id: project.id,
1125
+ orgId: 'org_1',
1126
+ spendLimit: '100000000',
1127
+ })
1128
+ const body = await relayRequest(createMainnetApp({ db }), {
1129
+ method: 'eth_signRawTransaction',
1130
+ params: [await signSponsorable({ chainId: Viem.chainId.mainnet })],
1131
+ projectId: project.id,
1132
+ token: 'secret_unattributed_prod',
1133
+ })
1134
+ expect(body.error).toBeUndefined()
1135
+ expect(await SponsoredTransactions.listPending(db)).toMatchObject([
1136
+ { billable: true, projectId: project.id },
1137
+ ])
1138
+ })
1139
+
1140
+ test('behavior: rejects request attribution from project-scoped keys', async () => {
1141
+ const body = await relayRequest(createMainnetApp(), {
1142
+ method: 'eth_signRawTransaction',
1143
+ params: [await signSponsorable({ chainId: Viem.chainId.mainnet })],
1144
+ projectId: 'prj_1',
1145
+ token: 'secret_sponsor_prod',
1146
+ })
1147
+ expect(body.error).toStrictEqual({
1148
+ code: -32602,
1149
+ data: { code: 'project_id_invalid' },
1150
+ message: 'Project attribution rejected.',
1151
+ })
1152
+ })
1153
+
1154
+ test('behavior: rejects request attribution outside the key organization', async () => {
1155
+ const db = TestApp.database()
1156
+ await Organizations.create(db, { id: 'org_2', name: 'Two' })
1157
+ const project = await Projects.create(db, { name: 'Other app', orgId: 'org_2' })
1158
+ const body = await relayRequest(createMainnetApp({ db }), {
1159
+ method: 'eth_signRawTransaction',
1160
+ params: [await signSponsorable({ chainId: Viem.chainId.mainnet })],
1161
+ projectId: project.id,
1162
+ token: 'secret_unattributed_prod',
1163
+ })
1164
+ expect(body.error?.data).toStrictEqual({ code: 'project_id_invalid' })
1165
+ })
1166
+
959
1167
  test('behavior: refuses mainnet sponsorship without a billing source', async () => {
960
1168
  const entries: Log.Entry[] = []
961
1169
  const body = await relayRequest(
@@ -1584,6 +1792,7 @@ async function relayRequest(app: ReturnType<typeof TestApp.create>, options: rel
1584
1792
  body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: options.method, params: options.params }), // prettier-ignore
1585
1793
  headers: {
1586
1794
  'content-type': 'application/json',
1795
+ ...(options.projectId ? { 'tempo-project-id': options.projectId } : {}),
1587
1796
  ...(options.token ? { 'tempo-api-key': options.token } : {}),
1588
1797
  },
1589
1798
  method: 'POST',
@@ -1604,6 +1813,8 @@ declare namespace relayRequest {
1604
1813
  mount?: 'relay' | 'sponsor' | undefined
1605
1814
  /** JSON-RPC parameters. */
1606
1815
  params: readonly unknown[]
1816
+ /** Request-level project attribution. */
1817
+ projectId?: string | undefined
1607
1818
  /** API-key credential. */
1608
1819
  token?: string | undefined
1609
1820
  }
@@ -7,9 +7,11 @@ import { Addresses, TokenId, Transaction } from 'viem/tempo'
7
7
  import type * as App from '../../App.js'
8
8
  import * as BillingSettings from '../../db/tables/billingSettings.js'
9
9
  import * as Db from '../../db/Db.js'
10
+ import * as Organizations from '../../db/tables/organizations.js'
11
+ import * as Projects from '../../db/tables/projects.js'
12
+ import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js'
10
13
  import * as Fees from '../../internal/Fees.js'
11
14
  import * as Log from '../../internal/Log.js'
12
- import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js'
13
15
  import * as Billing from '../management/Billing.js'
14
16
  import * as Handler from '../../Handler.js'
15
17
  import * as Auth from '../../internal/Auth.js'
@@ -85,6 +87,7 @@ function middleware(options: middleware.Options) {
85
87
  : 0n
86
88
  return createMiddleware<App.Environment>(async (c) => {
87
89
  const principal = Auth.getPrincipal(c)
90
+ const requestedAt = new Date().toISOString()
88
91
  const verifiedTokens = c.get('verifiedTokens')
89
92
  const zoneToken_header = c.req.header(ZoneRpcAuthentication.headerName)
90
93
  const zoneToken = zoneToken_header?.trim() ? zoneToken_header : undefined
@@ -97,7 +100,11 @@ function middleware(options: middleware.Options) {
97
100
 
98
101
  const recordRejection = (options: middleware.Rejection) => {
99
102
  const id =
100
- Number.isSafeInteger(options.chainId) && options.chainId >= 0 ? options.chainId : null
103
+ options.chainId !== undefined &&
104
+ Number.isSafeInteger(options.chainId) &&
105
+ options.chainId >= 0
106
+ ? options.chainId
107
+ : null
101
108
  if (chainId !== null) chainId = chainId === undefined || chainId === id ? id : null
102
109
  if (options.reason === 'internal_error') internalErrors = Math.min(internalErrors + 1, 65_535)
103
110
  if (reason !== null)
@@ -121,22 +128,49 @@ function middleware(options: middleware.Options) {
121
128
  throw new Error('Internal error', { cause: new Error(options.message) })
122
129
  }
123
130
 
124
- const feePayer = (() => {
131
+ const feePayer = await (async () => {
125
132
  if (!options.feePayer) return undefined
126
- const attribution =
127
- principal?.type === 'api_key'
128
- ? {
129
- apiKeyId: principal.id,
130
- environment: principal.environment,
131
- orgId: principal.orgId,
132
- ...(principal.projectId === undefined ? {} : { projectId: principal.projectId }),
133
- }
134
- : undefined
133
+ const resolved = await resolveAttribution(Db.get(c.get('db')), {
134
+ principal,
135
+ projectId: c.req.header('tempo-project-id')?.trim(),
136
+ })
137
+ const attribution = resolved?.attribution
138
+ const attributionError = resolved?.error
135
139
  const sponsorable =
136
140
  attribution !== undefined &&
137
141
  principal?.type === 'api_key' &&
138
142
  (principal.apiKey.scopes.includes('rpc-relay:sponsor') ||
139
143
  principal.apiKey.scopes.includes(Scope.wildcard))
144
+ let promotionState_promise: Promise<middleware.PromotionState>
145
+ const promotionState = () =>
146
+ (promotionState_promise ??= (async () => {
147
+ if (!attribution) return { status: 'inactive' }
148
+ const db = Db.get(c.get('db'))
149
+ const organization = await Organizations.get(db, attribution.orgId)
150
+ if (!organization || organization.sponsorshipSubsidyDurationDays === null)
151
+ return { status: 'inactive' }
152
+ // Missing project attribution opts out of the subsidy but must not interrupt sponsorship.
153
+ if (!attribution.projectId) return { status: 'inactive' }
154
+ const project = await Projects.get(db, attribution.projectId)
155
+ if (!project) return { message: 'Attributed project disappeared.', status: 'error' }
156
+ if (project.orgId !== attribution.orgId)
157
+ return { message: 'Attributed project changed ownership.', status: 'error' }
158
+ if (
159
+ (project.sponsorshipSubsidyStartsAt === null) !==
160
+ (project.sponsorshipSubsidyEndsAt === null)
161
+ )
162
+ return { message: 'Project promotion window is inconsistent.', status: 'error' }
163
+ if (
164
+ project.sponsorshipSubsidyEndsAt !== null &&
165
+ Date.parse(requestedAt) >= Date.parse(project.sponsorshipSubsidyEndsAt)
166
+ )
167
+ return { status: 'inactive' }
168
+ return { status: 'active' }
169
+ })())
170
+ const validatePromotion = async (chainId: number) => {
171
+ const state = await promotionState()
172
+ if (state.status === 'error') failSponsorship({ chainId, message: state.message })
173
+ }
140
174
  // Lifted so validation and the sponsored-token default read one value.
141
175
  const feeToken_sponsor = options.feePayer.feeToken ?? Addresses.pathUsd
142
176
  return {
@@ -147,6 +181,12 @@ function middleware(options: middleware.Options) {
147
181
  // only mainnet: the relay resolves chainId from the request body, so
148
182
  // the query-string chain guard never sees it.
149
183
  const id = Number(transaction.chainId)
184
+ if (attributionError)
185
+ rejectSponsorship({
186
+ chainId: id,
187
+ message: 'Project attribution rejected.',
188
+ reason: attributionError,
189
+ })
150
190
  if (!attribution) {
151
191
  if (sponsorshipRequired)
152
192
  rejectSponsorship({
@@ -186,15 +226,9 @@ function middleware(options: middleware.Options) {
186
226
  })
187
227
  return false
188
228
  }
189
-
190
- // Enforcement reads hit the primary, never the cached replica: a
191
- // stale `past_due`, a lowered limit, or another isolate's pending
192
- // spend must not keep the gate open.
193
229
  const db = Db.get(c.get('db'))
194
- // Mainnet sponsorship requires an active billing source; deployments
195
- // without billing configured have no rows, so mainnet stays closed.
196
- // `past_due` gets its own reason: the caller must settle an invoice,
197
- // not attach a card.
230
+ // Enforcement reads hit the primary, never the cached replica: a
231
+ // stale `past_due` or another isolate's state must not keep the gate open.
198
232
  const billing = await Billing.status(db, attribution.orgId)
199
233
  if (billing === 'past_due') {
200
234
  if (sponsorshipRequired)
@@ -214,6 +248,7 @@ function middleware(options: middleware.Options) {
214
248
  })
215
249
  return 'billing_required'
216
250
  }
251
+ await validatePromotion(id)
217
252
 
218
253
  const feeToken = (() => {
219
254
  try {
@@ -286,7 +321,6 @@ function middleware(options: middleware.Options) {
286
321
  // reconciliation convention; pending rows charge it to spend limits.
287
322
  const feeMax = Fees.maxOf(Transaction.deserialize(event.transaction as `0x76${string}`))
288
323
  const db = Db.get(c.get('db'))
289
-
290
324
  const feeToken = (() => {
291
325
  try {
292
326
  return event.feeToken !== undefined ? TokenId.toAddress(event.feeToken) : undefined
@@ -318,13 +352,54 @@ function middleware(options: middleware.Options) {
318
352
  // Re-read settings on the authoritative path: the limit may have
319
353
  // changed since `validate`, and only mainnet spend is limited.
320
354
  const settings = mainnet ? await BillingSettings.get(db, attribution.orgId) : undefined
321
- if (settings?.spendLimit == null) return void (await SponsoredTransactions.upsert(db, input)) // prettier-ignore
355
+ const billingLimit =
356
+ settings?.spendLimit == null
357
+ ? undefined
358
+ : {
359
+ chainIds: [Viem.chainId.mainnet],
360
+ max: Billing.toBaseUnits(settings.spendLimit),
361
+ since: Billing.periodStart(settings.period),
362
+ }
363
+ if (
364
+ mainnet &&
365
+ attribution.environment === 'production' &&
366
+ attribution.projectId !== undefined
367
+ )
368
+ try {
369
+ const promotion = await SponsoredTransactions.reservePromotion(
370
+ db,
371
+ { ...input, billable: false },
372
+ {
373
+ at: new Date().toISOString(),
374
+ ...(billingLimit ? { billingLimit } : {}),
375
+ chainIds: [Viem.chainId.mainnet],
376
+ projectId: attribution.projectId,
377
+ },
378
+ )
379
+ if (promotion.status !== 'ineligible') return
380
+ } catch (error) {
381
+ if (error instanceof SponsoredTransactions.AttributionConflictError)
382
+ rejectSponsorship({
383
+ chainId: event.chainId,
384
+ message: 'Project attribution rejected.',
385
+ reason: 'project_id_invalid',
386
+ })
387
+ if (error instanceof SponsoredTransactions.PromotionActivationError)
388
+ failSponsorship({
389
+ chainId: event.chainId,
390
+ message: 'Project promotion activation failed.',
391
+ })
392
+ if (error instanceof SponsoredTransactions.PeriodSpendLimitError)
393
+ rejectSponsorship({
394
+ chainId: event.chainId,
395
+ message: 'Spend limit exceeded.',
396
+ reason: 'spend_limit_exceeded',
397
+ })
398
+ throw error
399
+ }
400
+ if (!billingLimit) return void (await SponsoredTransactions.upsert(db, input))
322
401
  try {
323
- await SponsoredTransactions.reserve(db, input, {
324
- chainIds: [Viem.chainId.mainnet],
325
- max: Billing.toBaseUnits(settings.spendLimit),
326
- since: Billing.periodStart(settings.period),
327
- })
402
+ await SponsoredTransactions.reserve(db, input, billingLimit)
328
403
  } catch (error) {
329
404
  if (error instanceof SponsoredTransactions.PeriodSpendLimitError)
330
405
  rejectSponsorship({
@@ -518,6 +593,57 @@ function sponsorshipMethod(value: unknown): Log.SponsorshipMethod | undefined {
518
593
  }
519
594
  }
520
595
 
596
+ /** Resolves optional request-level project attribution within the API key's organization. */
597
+ async function resolveAttribution(
598
+ db: Db.Db,
599
+ options: resolveAttribution.Options,
600
+ ): Promise<resolveAttribution.Result | undefined> {
601
+ if (options.principal?.type !== 'api_key') return undefined
602
+ const { principal } = options
603
+ const attribution = {
604
+ apiKeyId: principal.id,
605
+ environment: principal.environment,
606
+ orgId: principal.orgId,
607
+ ...(principal.projectId === undefined ? {} : { projectId: principal.projectId }),
608
+ }
609
+ if (!options.projectId) return { attribution }
610
+ if (principal.projectId !== undefined) return { attribution, error: 'project_id_invalid' }
611
+ const project = await Projects.get(db, options.projectId)
612
+ if (!project || project.orgId !== principal.orgId)
613
+ return { attribution, error: 'project_id_invalid' }
614
+ return { attribution: { ...attribution, projectId: project.id } }
615
+ }
616
+
617
+ declare namespace resolveAttribution {
618
+ /** Inputs for resolving API-key project attribution. */
619
+ type Options = {
620
+ /** Authenticated request principal. */
621
+ principal: Auth.Principal | null
622
+ /** Optional project id supplied by the caller. */
623
+ projectId?: string | undefined
624
+ }
625
+
626
+ /** Resolved API-key attribution. */
627
+ type Attribution = {
628
+ /** API key id. */
629
+ apiKeyId: string
630
+ /** API key environment. */
631
+ environment: 'production' | 'sandbox'
632
+ /** API key organization id. */
633
+ orgId: string
634
+ /** Attributed project id. */
635
+ projectId?: string | undefined
636
+ }
637
+
638
+ /** Attribution result with an optional policy error. */
639
+ type Result = {
640
+ /** Attribution retained for request diagnostics. */
641
+ attribution: Attribution
642
+ /** Project attribution error detected before transaction validation. */
643
+ error?: 'project_id_invalid' | undefined
644
+ }
645
+ }
646
+
521
647
  declare namespace middleware {
522
648
  /** One invariant sponsorship failure. */
523
649
  type Failure = {
@@ -535,10 +661,13 @@ declare namespace middleware {
535
661
  path: 'rpc/relay' | 'rpc/sponsor'
536
662
  }
537
663
 
664
+ /** Cached promotion validation state; request accounting is applied per batch item. */
665
+ type PromotionState = { status: 'active' | 'inactive' } | { message: string; status: 'error' }
666
+
538
667
  /** One sponsorship rejection recorded for request logging. */
539
668
  type Rejection = {
540
669
  /** Candidate transaction chain, validated before it enters the log entry. */
541
- chainId: number
670
+ chainId?: number | undefined
542
671
  /** Stable client error code. */
543
672
  reason: Log.SponsorshipReason
544
673
  }
@@ -546,7 +675,7 @@ declare namespace middleware {
546
675
  /** One client-visible sponsorship refusal. */
547
676
  type Refusal = {
548
677
  /** Candidate transaction chain. */
549
- chainId: number
678
+ chainId?: number | undefined
550
679
  /** Client-safe error message. */
551
680
  message: string
552
681
  /** Stable client error code. */
@@ -11,6 +11,7 @@ import { getTransactionReceipt } from 'viem/actions'
11
11
 
12
12
  import * as Db from '../../db/Db.js'
13
13
  import * as BillingSettings from '../../db/tables/billingSettings.js'
14
+ import * as Projects from '../../db/tables/projects.js'
14
15
  import * as Fees from '../../internal/Fees.js'
15
16
  import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js'
16
17
  import * as Tidx from '../../internal/Tidx.js'
@@ -292,7 +293,29 @@ export function createFinalizer(options: createFinalizer.Options): createFinaliz
292
293
  getClient,
293
294
  getTidx: (chainId) => getTidx(chainId as Viem.ChainId),
294
295
  limitFor: async (intent) => {
295
- if (!intent.billable || !Viem.isMainnet(intent.chainId)) return undefined
296
+ if (!Viem.isMainnet(intent.chainId)) return undefined
297
+ if (!intent.billable) {
298
+ if (!intent.projectId) return undefined
299
+ const project = await Projects.get(db, intent.projectId)
300
+ // Missing promotion state cannot prove the intent remains within Tempo's budget.
301
+ if (!project || project.sponsorshipSubsidyStartsAt === null)
302
+ return {
303
+ billable: false,
304
+ chainIds: [Viem.chainId.mainnet],
305
+ max: -1n,
306
+ projectId: intent.projectId,
307
+ since: intent.createdAt,
308
+ }
309
+ if (project.sponsorshipSpendLimit === null) return undefined
310
+ return {
311
+ billable: false,
312
+ chainIds: [Viem.chainId.mainnet],
313
+ max: BigInt(project.sponsorshipSpendLimit),
314
+ projectId: intent.projectId,
315
+ since: project.sponsorshipSubsidyStartsAt,
316
+ }
317
+ }
318
+ if (!intent.billable) return undefined
296
319
  const settings = await BillingSettings.get(db, intent.orgId)
297
320
  if (settings?.spendLimit == null) return undefined
298
321
  return {
package/src/db/Db.ts CHANGED
@@ -1488,6 +1488,41 @@ export const migrations: readonly Migration[] = [
1488
1488
  up: (db) =>
1489
1489
  raw(db, sql`ALTER TABLE funding_routes ADD COLUMN IF NOT EXISTS configuration jsonb`),
1490
1490
  },
1491
+ {
1492
+ name: '0138_organizations_sponsorship_subsidy',
1493
+ up: (db) =>
1494
+ raw(
1495
+ db,
1496
+ sql`ALTER TABLE organizations
1497
+ ADD COLUMN IF NOT EXISTS sponsorship_subsidy_duration_days integer,
1498
+ ADD COLUMN IF NOT EXISTS sponsorship_subsidy_project_spend_limit text`,
1499
+ ),
1500
+ },
1501
+ {
1502
+ name: '0139_projects_sponsorship_activation',
1503
+ up: (db) =>
1504
+ raw(
1505
+ db,
1506
+ sql`ALTER TABLE projects
1507
+ ADD COLUMN IF NOT EXISTS sponsorship_subsidy_starts_at text,
1508
+ ADD COLUMN IF NOT EXISTS sponsorship_subsidy_ends_at text,
1509
+ DROP CONSTRAINT IF EXISTS projects_sponsorship_subsidy_window_consistent,
1510
+ ADD CONSTRAINT projects_sponsorship_subsidy_window_consistent CHECK ((sponsorship_subsidy_starts_at IS NULL) = (sponsorship_subsidy_ends_at IS NULL))`,
1511
+ ),
1512
+ },
1513
+ {
1514
+ name: '0140_projects_sponsorship_spend_limit',
1515
+ up: (db) =>
1516
+ raw(db, sql`ALTER TABLE projects ADD COLUMN IF NOT EXISTS sponsorship_spend_limit text`),
1517
+ },
1518
+ {
1519
+ name: '0141_sponsored_transactions_promotion_spend_index',
1520
+ up: (db) =>
1521
+ raw(
1522
+ db,
1523
+ sql`CREATE INDEX IF NOT EXISTS sponsored_transactions_promotion_spend_idx ON sponsored_transactions (org_id, project_id, status, created_at) WHERE NOT billable`,
1524
+ ),
1525
+ },
1491
1526
  ]
1492
1527
 
1493
1528
  /**
package/src/db/Schema.ts CHANGED
@@ -219,6 +219,12 @@ export const Organization = z.object({
219
219
  name: z
220
220
  .string()
221
221
  .check(z.describe('Human-readable organization name.'), z.meta({ examples: ['Acme, Inc.'] })),
222
+ sponsorshipSubsidyDurationDays: z
223
+ .nullable(z.number().check(z.int(), z.positive()))
224
+ .check(z.describe('Promotional sponsorship duration in days, or null when disabled.')),
225
+ sponsorshipSubsidyProjectSpendLimit: z
226
+ .nullable(z.string())
227
+ .check(z.describe('Per-project promotional spend cap in decimal USD, or null when disabled.')),
222
228
  updatedAt: z.string().check(z.describe('When the organization was last updated (ISO 8601).')),
223
229
  userId: z
224
230
  .nullable(z.string())
@@ -243,6 +249,15 @@ export const Project = z.object({
243
249
  .string()
244
250
  .check(z.describe('Human-readable project name.'), z.meta({ examples: ['Checkout'] })),
245
251
  orgId: z.string().check(z.describe('Owning organization id (`org_…`).')),
252
+ sponsorshipSubsidyStartsAt: z
253
+ .nullable(z.string())
254
+ .check(z.describe('When the project first activated promotional sponsorship, or null.')),
255
+ sponsorshipSubsidyEndsAt: z
256
+ .nullable(z.string())
257
+ .check(z.describe('When the project promotional sponsorship ends, or null.')),
258
+ sponsorshipSpendLimit: z
259
+ .nullable(z.string())
260
+ .check(z.describe('Promotional spend cap in fee-token base units, or null.')),
246
261
  updatedAt: z.string().check(z.describe('When the project was last updated (ISO 8601).')),
247
262
  })
248
263
 
@@ -640,8 +655,8 @@ export const FundingTransfer = z.object({
640
655
  .check(z.describe('Attributed project id (`prj_…`), or null for organization-attributed keys.')), // prettier-ignore
641
656
  providerId: z.string().check(z.describe('Selected funding provider id.')),
642
657
  providerState: z
643
- .nullable(z.custom<funding_Provider.prepareTransfer.Correlation>())
644
- .check(z.describe('Private provider correlation (`jsonb`), or null. Never serialized publicly.')), // prettier-ignore
658
+ .nullable(z.custom<funding_Transfer.ProviderState>())
659
+ .check(z.describe('Private provider state (`jsonb`), or null. Never serialized publicly.')), // prettier-ignore
645
660
  quoteExpiresAt: z
646
661
  .string()
647
662
  .check(z.describe('When the quoted terms stop being executable (ISO 8601).')),