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
@@ -2,12 +2,15 @@ import * as z from 'zod/mini'
2
2
 
3
3
  import type * as Db from '../../db/Db.js'
4
4
  import * as FundingTransferEvents from '../../db/tables/fundingTransferEvents.js'
5
+ import * as FundingTransferTransactions from '../../db/tables/fundingTransferTransactions.js'
5
6
  import * as FundingTransfers from '../../db/tables/fundingTransfers.js'
6
7
  import * as Id from '../Id.js'
7
8
  import * as OpenApi from '../OpenApi.js'
8
9
  import * as Schema from '../Schema.js'
10
+ import type * as Webhooks from '../Webhooks.js'
9
11
  import * as Action from './Action.js'
10
12
  import * as Provider from './Provider.js'
13
+ import * as TransferWebhook from './TransferWebhook.js'
11
14
 
12
15
  /** Public funding transfer statuses, in the order documented by the API. */
13
16
  export const statuses = [
@@ -61,7 +64,7 @@ const transitions: Record<Status, readonly Status[]> = {
61
64
  'awaiting-source': ['action-required', 'expired', 'processing'],
62
65
  completed: [],
63
66
  expired: [],
64
- processing: ['action-required', 'completed', 'refunding'],
67
+ processing: ['action-required', 'awaiting-source', 'completed', 'refunding'],
65
68
  refunded: [],
66
69
  refunding: ['action-required', 'refunded'],
67
70
  }
@@ -383,12 +386,22 @@ export const transactionRoles = ['destination', 'refund', 'source'] as const
383
386
  /** Role of a verified transaction reference on a transfer. */
384
387
  export type TransactionRole = (typeof transactionRoles)[number]
385
388
 
389
+ /** Bounded private provider state retained for transfer reconciliation. */
390
+ export type ProviderState = Record<string, unknown>
391
+
392
+ type Staged = {
393
+ /** Funding transfer record committed with the webhook obligations. */
394
+ record: FundingTransfers.Record
395
+ /** Webhook queue references committed with the transfer. */
396
+ references: Webhooks.QueueReference[]
397
+ }
398
+
386
399
  /**
387
400
  * Creates a durable funding transfer: the row plus its version-1 event commit
388
401
  * in one transaction, so every stored version has an immutable public record.
389
402
  */
390
403
  export async function create(db: Db.Db, input: create.Input): Promise<FundingTransfers.Record> {
391
- return db.transaction((tx) => createIn(tx, input))
404
+ return db.transaction(async (tx) => (await createIn(tx, input)).record)
392
405
  }
393
406
 
394
407
  /**
@@ -396,7 +409,7 @@ export async function create(db: Db.Db, input: create.Input): Promise<FundingTra
396
409
  * transaction, for writes that must commit atomically with sibling state
397
410
  * (idempotency completion).
398
411
  */
399
- export async function createIn(tx: Db.Db, input: create.Input): Promise<FundingTransfers.Record> {
412
+ export async function createIn(tx: Db.Db, input: create.Input): Promise<createIn.Result> {
400
413
  const now = new Date()
401
414
  const snapshot = parseSnapshot(input.snapshot)
402
415
  const record: FundingTransfers.Record = {
@@ -418,14 +431,22 @@ export async function createIn(tx: Db.Db, input: create.Input): Promise<FundingT
418
431
  version: 1,
419
432
  }
420
433
  const created = await FundingTransfers.insert(tx, record)
421
- await FundingTransferEvents.insert(tx, {
434
+ const event = await FundingTransferEvents.insert(tx, {
422
435
  createdAt: record.createdAt,
423
436
  snapshot: toPublic(created),
424
437
  status: created.status,
425
438
  transferId: created.id,
426
439
  version: created.version,
427
440
  })
428
- return created
441
+ const { references } = await TransferWebhook.stage(tx, {
442
+ changes: [{ event, record: created }],
443
+ })
444
+ return { record: created, references }
445
+ }
446
+
447
+ export declare namespace createIn {
448
+ /** Created transfer and webhook queue references committed with it. */
449
+ type Result = Staged
429
450
  }
430
451
 
431
452
  export declare namespace create {
@@ -442,12 +463,193 @@ export declare namespace create {
442
463
  /** Attributed project id, when the creating key is project-attributed. */
443
464
  projectId?: string | undefined
444
465
  /** Private provider correlation retained for reconciliation. */
445
- providerState?: Provider.prepareTransfer.Correlation | undefined
466
+ providerState?: ProviderState | undefined
446
467
  /** The public snapshot captured from the selected quote. */
447
468
  snapshot: Snapshot
448
469
  }
449
470
  }
450
471
 
472
+ /** Atomically assigns one verified source transaction and starts reconciliation. */
473
+ export async function registerSourceTransaction(
474
+ db: Db.Db,
475
+ options: registerSourceTransaction.Options,
476
+ ): Promise<registerSourceTransaction.Result> {
477
+ return db.transaction(async (tx) => {
478
+ const current = await FundingTransfers.getForUpdate(tx, options.id)
479
+ if (!current) return { type: 'not_found' }
480
+
481
+ const existing = await FundingTransferTransactions.getSource(tx, {
482
+ chainId: options.chainId,
483
+ transactionRef: options.transactionHash,
484
+ })
485
+ if (existing) {
486
+ if (existing.transferId !== current.id) return { current, type: 'conflict' }
487
+ if (current.status !== 'processing' && current.status !== 'completed')
488
+ return { current, type: 'invalid' }
489
+ return { record: current, type: 'replayed' }
490
+ }
491
+ if (current.status !== 'awaiting-source') return { current, type: 'invalid' }
492
+
493
+ const claimed = await FundingTransferTransactions.claimSource(tx, {
494
+ chainId: options.chainId,
495
+ createdAt: new Date().toISOString(),
496
+ role: 'source',
497
+ transactionRef: options.transactionHash,
498
+ transferId: options.id,
499
+ })
500
+ if (claimed.type === 'conflict') return { current, type: 'conflict' }
501
+ if (claimed.type === 'replayed') return { record: current, type: 'replayed' }
502
+
503
+ const result = await transitionIn(tx, {
504
+ current,
505
+ expectedVersion: current.version,
506
+ id: current.id,
507
+ providerState: { ...current.providerState, ...options.providerState },
508
+ snapshot: {
509
+ ...current.snapshot,
510
+ sourceTransactionHashes: [claimed.record.transactionRef],
511
+ },
512
+ status: 'processing',
513
+ })
514
+ if (result.type !== 'applied') throw new AtomicTransitionError()
515
+ return { record: result.record, references: result.references, type: 'registered' }
516
+ })
517
+ }
518
+
519
+ export declare namespace registerSourceTransaction {
520
+ /** Verified source evidence and private provider correlation. */
521
+ type Options = {
522
+ /** Source chain CAIP-2 id. */
523
+ chainId: string
524
+ /** Transfer id (`ftr_…`). */
525
+ id: string
526
+ /** Private state added for destination reconciliation. */
527
+ providerState: ProviderState
528
+ /** Verified source transaction hash. */
529
+ transactionHash: string
530
+ }
531
+
532
+ /** Source transaction registration outcome. */
533
+ type Result =
534
+ | { current: FundingTransfers.Record; type: 'conflict' }
535
+ | { current: FundingTransfers.Record; type: 'invalid' }
536
+ | (Staged & { type: 'registered' })
537
+ | { record: FundingTransfers.Record; type: 'replayed' }
538
+ | { type: 'not_found' }
539
+ }
540
+
541
+ /** Atomically removes invalidated source evidence and accepts a replacement. */
542
+ export async function reopenSourceTransaction(
543
+ db: Db.Db,
544
+ options: reopenSourceTransaction.Options,
545
+ ): Promise<reopenSourceTransaction.Result> {
546
+ return db.transaction(async (tx) => {
547
+ const current = await FundingTransfers.getForUpdate(tx, options.id)
548
+ if (!current) return { type: 'not_found' }
549
+ if (current.status !== 'processing') return { current, type: 'invalid' }
550
+
551
+ const removed = await FundingTransferTransactions.removeSource(tx, {
552
+ chainId: options.chainId,
553
+ transactionHash: options.transactionHash,
554
+ transferId: current.id,
555
+ })
556
+ if (!removed) return { current, type: 'invalid' }
557
+ const { sourceTransactionHashes: _sourceTransactionHashes, ...snapshot } = current.snapshot
558
+ const result = await transitionIn(tx, {
559
+ current,
560
+ expectedVersion: current.version,
561
+ id: current.id,
562
+ providerState: options.providerState,
563
+ snapshot,
564
+ status: 'awaiting-source',
565
+ })
566
+ if (result.type !== 'applied') throw new AtomicTransitionError()
567
+ return { record: result.record, references: result.references, type: 'reopened' }
568
+ })
569
+ }
570
+
571
+ export declare namespace reopenSourceTransaction {
572
+ /** Source evidence to remove after it disappears from the canonical chain. */
573
+ type Options = {
574
+ /** Source chain CAIP-2 id. */
575
+ chainId: string
576
+ /** Transfer id (`ftr_…`). */
577
+ id: string
578
+ /** Private provider state retained while awaiting replacement evidence. */
579
+ providerState: ProviderState
580
+ /** Invalidated source transaction hash. */
581
+ transactionHash: string
582
+ }
583
+
584
+ /** Source transaction reopening outcome. */
585
+ type Result =
586
+ | { current: FundingTransfers.Record; type: 'invalid' }
587
+ | (Staged & { type: 'reopened' })
588
+ | { type: 'not_found' }
589
+ }
590
+
591
+ /** Atomically records a verified destination transaction and completes a transfer. */
592
+ export async function complete(db: Db.Db, options: complete.Options): Promise<complete.Result> {
593
+ return db.transaction(async (tx) => {
594
+ const current = await FundingTransfers.getForUpdate(tx, options.id)
595
+ if (!current) return { type: 'not_found' }
596
+ if (current.status === 'completed') {
597
+ const transactions = await FundingTransferTransactions.listByTransfer(tx, current.id)
598
+ const existing = transactions.some(
599
+ (transaction) =>
600
+ transaction.chainId === options.chainId &&
601
+ transaction.role === 'destination' &&
602
+ transaction.transactionRef === options.transactionHash.toLowerCase(),
603
+ )
604
+ return existing ? { record: current, type: 'replayed' } : { current, type: 'invalid' }
605
+ }
606
+ if (current.status !== 'processing') return { current, type: 'invalid' }
607
+
608
+ const transaction = await FundingTransferTransactions.insert(tx, {
609
+ chainId: options.chainId,
610
+ createdAt: new Date().toISOString(),
611
+ role: 'destination',
612
+ transactionRef: options.transactionHash,
613
+ transferId: options.id,
614
+ })
615
+ if (!transaction) throw new AtomicTransitionError()
616
+ const result = await transitionIn(tx, {
617
+ current,
618
+ expectedVersion: current.version,
619
+ id: current.id,
620
+ snapshot: {
621
+ ...current.snapshot,
622
+ destinationAmount: options.destinationAmount,
623
+ destinationTransactionHashes: [transaction.transactionRef],
624
+ },
625
+ status: 'completed',
626
+ })
627
+ if (result.type !== 'applied') throw new AtomicTransitionError()
628
+ return { record: result.record, references: result.references, type: 'completed' }
629
+ })
630
+ }
631
+
632
+ export declare namespace complete {
633
+ /** Verified destination evidence for one processing transfer. */
634
+ type Options = {
635
+ /** Destination chain CAIP-2 id. */
636
+ chainId: string
637
+ /** Exact verified destination amount. */
638
+ destinationAmount: NonNullable<Snapshot['destinationAmount']>
639
+ /** Transfer id (`ftr_…`). */
640
+ id: string
641
+ /** Verified destination transaction hash. */
642
+ transactionHash: string
643
+ }
644
+
645
+ /** Transfer completion outcome. */
646
+ type Result =
647
+ | { current: FundingTransfers.Record; type: 'invalid' }
648
+ | (Staged & { type: 'completed' })
649
+ | { record: FundingTransfers.Record; type: 'replayed' }
650
+ | { type: 'not_found' }
651
+ }
652
+
451
653
  /**
452
654
  * Applies a version-guarded material change: a status transition, a snapshot
453
655
  * update, or both. The row update and its immutable event commit in one
@@ -457,39 +659,48 @@ export declare namespace create {
457
659
  export async function transition(
458
660
  db: Db.Db,
459
661
  options: transition.Options,
662
+ ): Promise<transition.Result> {
663
+ return db.transaction((tx) => transitionIn(tx, options))
664
+ }
665
+
666
+ async function transitionIn(
667
+ tx: Db.Db,
668
+ options: transition.Options & { current?: FundingTransfers.Record | undefined },
460
669
  ): Promise<transition.Result> {
461
670
  const now = new Date()
462
- return db.transaction(async (tx) => {
463
- const current = await FundingTransfers.get(tx, options.id)
464
- if (!current) return { type: 'not_found' }
465
- if (current.version !== options.expectedVersion) return { current, type: 'stale' }
466
- const status = options.status ?? current.status
467
- if (status !== current.status && !canTransition(current.status, status))
468
- return { current, type: 'invalid' }
469
-
470
- const updated = await FundingTransfers.update(tx, {
471
- expectedVersion: options.expectedVersion,
472
- id: options.id,
473
- snapshot: options.snapshot === undefined ? current.snapshot : parseSnapshot(options.snapshot),
474
- status,
475
- statusReason:
476
- options.statusReason ?? (status === current.status ? current.statusReason : null),
477
- updatedAt: now.toISOString(),
478
- version: current.version + 1,
479
- })
480
- if (!updated) {
481
- const latest = await FundingTransfers.get(tx, options.id)
482
- return latest ? { current: latest, type: 'stale' } : { type: 'not_found' }
483
- }
484
- await FundingTransferEvents.insert(tx, {
485
- createdAt: updated.updatedAt,
486
- snapshot: toPublic(updated),
487
- status: updated.status,
488
- transferId: updated.id,
489
- version: updated.version,
490
- })
491
- return { record: updated, type: 'applied' }
671
+ const current = options.current ?? (await FundingTransfers.get(tx, options.id))
672
+ if (!current) return { type: 'not_found' }
673
+ if (current.version !== options.expectedVersion) return { current, type: 'stale' }
674
+ const status = options.status ?? current.status
675
+ if (status !== current.status && !canTransition(current.status, status))
676
+ return { current, type: 'invalid' }
677
+
678
+ const updated = await FundingTransfers.update(tx, {
679
+ expectedVersion: options.expectedVersion,
680
+ id: options.id,
681
+ providerState:
682
+ options.providerState === undefined ? current.providerState : options.providerState,
683
+ snapshot: options.snapshot === undefined ? current.snapshot : parseSnapshot(options.snapshot),
684
+ status,
685
+ statusReason: options.statusReason ?? (status === current.status ? current.statusReason : null),
686
+ updatedAt: now.toISOString(),
687
+ version: current.version + 1,
492
688
  })
689
+ if (!updated) {
690
+ const latest = await FundingTransfers.get(tx, options.id)
691
+ return latest ? { current: latest, type: 'stale' } : { type: 'not_found' }
692
+ }
693
+ const event = await FundingTransferEvents.insert(tx, {
694
+ createdAt: updated.updatedAt,
695
+ snapshot: toPublic(updated),
696
+ status: updated.status,
697
+ transferId: updated.id,
698
+ version: updated.version,
699
+ })
700
+ const { references } = await TransferWebhook.stage(tx, {
701
+ changes: [{ event, record: updated }],
702
+ })
703
+ return { record: updated, references, type: 'applied' }
493
704
  }
494
705
 
495
706
  export declare namespace transition {
@@ -499,6 +710,8 @@ export declare namespace transition {
499
710
  expectedVersion: number
500
711
  /** Transfer id (`ftr_…`). */
501
712
  id: string
713
+ /** Replacement private provider state; omitted keeps the stored value. */
714
+ providerState?: ProviderState | null | undefined
502
715
  /** Replacement public snapshot; omitted keeps the stored snapshot. */
503
716
  snapshot?: Snapshot | undefined
504
717
  /** New status; omitted keeps the current status (snapshot-only change). */
@@ -509,7 +722,7 @@ export declare namespace transition {
509
722
 
510
723
  /** Outcome of a guarded transition. */
511
724
  type Result =
512
- | { record: FundingTransfers.Record; type: 'applied' }
725
+ | (Staged & { type: 'applied' })
513
726
  | { current: FundingTransfers.Record; type: 'invalid' }
514
727
  | { current: FundingTransfers.Record; type: 'stale' }
515
728
  | { type: 'not_found' }
@@ -543,3 +756,7 @@ function parseSnapshot(snapshot: Snapshot): Snapshot {
543
756
  },
544
757
  }
545
758
  }
759
+
760
+ class AtomicTransitionError extends Error {
761
+ override name = 'Funding.Transfer.AtomicTransitionError'
762
+ }
@@ -0,0 +1,147 @@
1
+ import { tempoMainnet } from 'viem/tempo/chains'
2
+
3
+ import * as Anvil from '../../../test/Anvil.js'
4
+ import * as TestApp from '../../../test/App.js'
5
+ import * as TestFunding from '../../../test/Funding.js'
6
+ import * as Catalog from './Catalog.js'
7
+ import * as Transfer from './Transfer.js'
8
+ import * as TransferReconciliation from './TransferReconciliation.js'
9
+
10
+ const db = TestApp.database()
11
+ const destinationTransactionHash =
12
+ '0x3dfaba0a78b8f4bc09e55e05211c8120729e7b6879efe32613883befbca666da'
13
+ const guid = '0x5549d117c8b23f0ecdf05c2604ed989cf0034f378707bd81319e93834f2f85f2'
14
+ const recipient = '0x7212623e4cff9ad010d28aa065fad2bf58578783'
15
+ const sender = '0x52b650a62e384f68880d0a1dc2b84dfed47d5660'
16
+ const sourceTransactionHash = '0xfd71359adf5095f2ab512a19e0a2df6c8cc0db14ac8a52286d61287daabdccd4'
17
+ const routeConfiguration = TestFunding.snapshot.routesByProvider
18
+ .get('stargate')!
19
+ .find((candidate) => candidate.route.source.chain.id === 'eip155:8453')!.configuration!
20
+
21
+ describe.skipIf(!Anvil.available)('createTracker', () => {
22
+ let destination: Anvil.start.ReturnType
23
+
24
+ beforeAll(async () => {
25
+ destination = await Anvil.start({
26
+ chain: tempoMainnet,
27
+ forkUrl: tempoMainnet.rpcUrls.default.http[0]!,
28
+ })
29
+ await Catalog.publish(db, {
30
+ ...TestFunding.data,
31
+ chains: TestFunding.data.chains.map((chain) =>
32
+ chain.id === 'eip155:4217' ? { ...chain, rpcUrls: [destination.rpcUrl] } : chain,
33
+ ),
34
+ })
35
+ })
36
+
37
+ afterAll(async () => {
38
+ await destination?.stop()
39
+ })
40
+
41
+ test('completes a registered transfer from Tempo evidence', async () => {
42
+ const amount = {
43
+ baseUnits: '200000',
44
+ currency: 'USD',
45
+ decimals: 6,
46
+ formatted: '0.2',
47
+ } as const
48
+ const transfer = await Transfer.create(db, {
49
+ apiKeyId: 'key_test',
50
+ environment: 'production',
51
+ orgId: 'org_test',
52
+ providerState: {
53
+ destinationBlockNumber: (
54
+ await destination.client.getTransactionReceipt({ hash: destinationTransactionHash })
55
+ ).blockNumber.toString(),
56
+ routeConfiguration,
57
+ },
58
+ snapshot: TestFunding.transferSnapshot({
59
+ destinationAmount: amount,
60
+ destinationAmountMin: amount,
61
+ provider: { id: 'stargate', name: 'Stargate' },
62
+ recipient,
63
+ sender,
64
+ sourceAmount: amount,
65
+ }),
66
+ })
67
+ await Transfer.registerSourceTransaction(db, {
68
+ chainId: 'eip155:8453',
69
+ id: transfer.id,
70
+ providerState: { stargate: { guid, sourceEid: 30_184 } },
71
+ transactionHash: sourceTransactionHash,
72
+ })
73
+ await Catalog.publish(db, {
74
+ ...TestFunding.data,
75
+ chains: TestFunding.data.chains.map((chain) =>
76
+ chain.id === 'eip155:4217' ? { ...chain, rpcUrls: [destination.rpcUrl] } : chain,
77
+ ),
78
+ routes: TestFunding.data.routes.filter((route) => route.providerId !== 'stargate'),
79
+ })
80
+
81
+ const tracker = TransferReconciliation.createTracker({ db })
82
+ await expect(
83
+ tracker.reconcile({ transferId: transfer.id, type: 'funding:transfer:reconcile' }),
84
+ ).resolves.toEqual({ type: 'completed' })
85
+
86
+ const completed = await Transfer.transition(db, {
87
+ expectedVersion: 2,
88
+ id: transfer.id,
89
+ status: 'completed',
90
+ })
91
+ expect(completed.type).toBe('stale')
92
+ if (completed.type !== 'stale') return
93
+ expect({
94
+ destinationTransactionHashes: completed.current.snapshot.destinationTransactionHashes,
95
+ status: completed.current.status,
96
+ version: completed.current.version,
97
+ }).toEqual({
98
+ destinationTransactionHashes: [destinationTransactionHash],
99
+ status: 'completed',
100
+ version: 3,
101
+ })
102
+ })
103
+ })
104
+
105
+ describe('tick', () => {
106
+ test('advances through bounded recovery pages', async () => {
107
+ const database = TestApp.database()
108
+ await TestFunding.publish(database)
109
+ for (let index = 0; index < 26; index++) {
110
+ const transfer = await Transfer.create(database, {
111
+ apiKeyId: 'key_test',
112
+ environment: 'production',
113
+ id: Transfer.generateId(new Date(Date.UTC(2026, 0, 1, 0, 0, index))),
114
+ orgId: 'org_test',
115
+ providerState: {},
116
+ snapshot: TestFunding.transferSnapshot({
117
+ provider: { id: 'stargate', name: 'Stargate' },
118
+ }),
119
+ })
120
+ await Transfer.transition(database, {
121
+ expectedVersion: 1,
122
+ id: transfer.id,
123
+ status: 'processing',
124
+ })
125
+ }
126
+
127
+ type Dispatch = { delaySeconds: number; message: TransferReconciliation.Message }
128
+ const dispatches: Dispatch[] = []
129
+ let available = false
130
+ const tracker = TransferReconciliation.createTracker({
131
+ db: database,
132
+ dispatch: async (message, options) => {
133
+ dispatches.push({ delaySeconds: options.delaySeconds, message })
134
+ if (!available) throw new Error('queue unavailable')
135
+ },
136
+ })
137
+ await expect(tracker.tick()).rejects.toThrow('queue unavailable')
138
+ available = true
139
+ await expect(tracker.tick()).resolves.toEqual({ checked: 25 })
140
+ await expect(tracker.tick()).resolves.toEqual({ checked: 1 })
141
+
142
+ expect(dispatches).toHaveLength(27)
143
+ expect(dispatches[0]).toEqual(dispatches[1])
144
+ expect(new Set(dispatches.map(({ delaySeconds }) => delaySeconds))).toEqual(new Set([0]))
145
+ expect(new Set(dispatches.map(({ message }) => message.transferId)).size).toBe(26)
146
+ })
147
+ })