@unicitylabs/sphere-sdk 0.14.0-dev.5 → 0.14.0-dev.6

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.
@@ -549,6 +549,10 @@ function isValidationReject(err) {
549
549
  if (e === null) return false;
550
550
  return e.status === 422 || e.statusCode === 422 || e.code === "VALIDATION" || e.code === "VALIDATION_FAILED";
551
551
  }
552
+ function retryAfterMsOf(err) {
553
+ const retryAfter = err !== null && typeof err === "object" ? err.retryAfter : void 0;
554
+ return typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
555
+ }
552
556
  var chainRegistry = /* @__PURE__ */ new WeakMap();
553
557
  var replayRegistry = /* @__PURE__ */ new WeakMap();
554
558
  function registryFor(registry, kv, storeKey, make) {
@@ -1225,6 +1229,217 @@ function validatePayload(raw) {
1225
1229
  return p;
1226
1230
  }
1227
1231
 
1232
+ // modules/payments-v2/convergence.ts
1233
+ var HEARTBEAT_SEED_MS = 5e3;
1234
+ var HEARTBEAT_CAP_MS = 12e4;
1235
+ var ConvergenceHeartbeat = class {
1236
+ constructor(deps) {
1237
+ this.deps = deps;
1238
+ }
1239
+ timer = null;
1240
+ delayMs = HEARTBEAT_SEED_MS;
1241
+ notBefore = 0;
1242
+ running = false;
1243
+ start() {
1244
+ this.running = true;
1245
+ this.delayMs = HEARTBEAT_SEED_MS;
1246
+ this.notBefore = 0;
1247
+ }
1248
+ /** §7 quiescence unchanged: a mid-backoff stop() cancels cleanly. */
1249
+ stop() {
1250
+ this.running = false;
1251
+ this.clearTimer();
1252
+ }
1253
+ /** Reset to seed on progress, ×2 to the cap otherwise; idle stops rescheduling. */
1254
+ settle(outcome, pending) {
1255
+ if (outcome.retryAfterMs !== null) {
1256
+ this.notBefore = Math.max(this.notBefore, this.deps.now() + outcome.retryAfterMs);
1257
+ }
1258
+ if (!pending) {
1259
+ this.delayMs = HEARTBEAT_SEED_MS;
1260
+ return;
1261
+ }
1262
+ const delay = outcome.progress ? HEARTBEAT_SEED_MS : this.delayMs;
1263
+ this.delayMs = Math.min(delay * 2, HEARTBEAT_CAP_MS);
1264
+ this.schedule(delay);
1265
+ }
1266
+ /** New pending work (keep-open park / journaled delivery / mint hold): prompt retry. */
1267
+ arm() {
1268
+ if (!this.running) return;
1269
+ this.delayMs = Math.min(HEARTBEAT_SEED_MS * 2, HEARTBEAT_CAP_MS);
1270
+ this.schedule(HEARTBEAT_SEED_MS);
1271
+ }
1272
+ /** connection:status recovery: the backoff resets; a pending tick re-fires at the seed. */
1273
+ recovered() {
1274
+ this.delayMs = HEARTBEAT_SEED_MS;
1275
+ if (this.timer !== null) this.arm();
1276
+ }
1277
+ schedule(delayMs) {
1278
+ this.clearTimer();
1279
+ if (!this.running) return;
1280
+ const floored = Math.max(delayMs, this.notBefore - this.deps.now());
1281
+ this.timer = setTimeout(() => {
1282
+ this.timer = null;
1283
+ this.deps.onTick();
1284
+ }, floored);
1285
+ }
1286
+ clearTimer() {
1287
+ if (this.timer !== null) clearTimeout(this.timer);
1288
+ this.timer = null;
1289
+ }
1290
+ };
1291
+ var Converger = class {
1292
+ constructor(deps) {
1293
+ this.deps = deps;
1294
+ }
1295
+ /** Resume open intents, then drain the delivery + mint journals (all idempotent). */
1296
+ async convergeOnce() {
1297
+ let progress = false;
1298
+ let retryAfterMs = null;
1299
+ try {
1300
+ progress = await this.resumePass();
1301
+ } catch (err) {
1302
+ retryAfterMs = retryAfterMsOf(err);
1303
+ }
1304
+ try {
1305
+ const stats = await this.deps.stores.deliveryJournal.replay(this.replayDeps());
1306
+ progress = progress || stats.delivered > 0;
1307
+ } catch (err) {
1308
+ retryAfterMs ??= retryAfterMsOf(err);
1309
+ }
1310
+ try {
1311
+ progress = await this.deps.replayMints() > 0 || progress;
1312
+ } catch (err) {
1313
+ retryAfterMs ??= retryAfterMsOf(err);
1314
+ }
1315
+ return { progress, retryAfterMs };
1316
+ }
1317
+ /** §4 heartbeat predicate — read fresh from the §6 stores, never cached. */
1318
+ async pendingWork() {
1319
+ const [backstop, journal, mints] = await Promise.all([
1320
+ this.deps.stores.backstop.list(),
1321
+ this.deps.stores.deliveryJournal.list(),
1322
+ this.deps.stores.mintJournal.list()
1323
+ ]);
1324
+ return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
1325
+ }
1326
+ async resumePass() {
1327
+ const report = await resumeAll(this.filteredMachineDeps());
1328
+ const progress = report.resumed.length > 0 || report.conflicted.length > 0;
1329
+ await this.deps.reconcile({
1330
+ resumed: report.resumed,
1331
+ conflicted: report.conflicted,
1332
+ open: report.failed
1333
+ });
1334
+ if (progress) await this.deps.refreshView().catch(() => void 0);
1335
+ return progress;
1336
+ }
1337
+ filteredMachineDeps() {
1338
+ const intents = this.deps.machineDeps.intents;
1339
+ return {
1340
+ ...this.deps.machineDeps,
1341
+ intents: {
1342
+ ...intents,
1343
+ listOpen: async () => (await intents.listOpen()).filter((intent) => !this.deps.isActiveOp(intent.transferId))
1344
+ }
1345
+ };
1346
+ }
1347
+ replayDeps() {
1348
+ return {
1349
+ delivery: this.deps.delivery,
1350
+ now: () => this.deps.now(),
1351
+ attention: (transferId, code, detail) => {
1352
+ this.deps.emit(
1353
+ "transfer:attention",
1354
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
1355
+ );
1356
+ }
1357
+ };
1358
+ }
1359
+ };
1360
+ async function derivePendingTransfers(stores, decryptPayload) {
1361
+ const [backstop, journal, shortfalls] = await Promise.all([
1362
+ stores.backstop.list(),
1363
+ stores.deliveryJournal.list(),
1364
+ stores.shortfalls.list()
1365
+ ]);
1366
+ const byTransfer = groupJournal(journal);
1367
+ const out = [];
1368
+ const seen = /* @__PURE__ */ new Set();
1369
+ for (const entry of backstop) {
1370
+ if (entry.disposition !== "open") continue;
1371
+ seen.add(entry.transferId);
1372
+ out.push(await openRow(entry, byTransfer.get(entry.transferId) ?? [], decryptPayload));
1373
+ }
1374
+ for (const [transferId, entries] of byTransfer) {
1375
+ if (seen.has(transferId)) continue;
1376
+ seen.add(transferId);
1377
+ out.push(journalOnlyRow(transferId, entries));
1378
+ }
1379
+ for (const entry of shortfalls) {
1380
+ if (!seen.has(entry.transferId)) out.push(shortfallRow(entry));
1381
+ }
1382
+ return out;
1383
+ }
1384
+ function groupJournal(journal) {
1385
+ const byTransfer = /* @__PURE__ */ new Map();
1386
+ for (const entry of journal) {
1387
+ const mine = byTransfer.get(entry.transferId);
1388
+ if (mine === void 0) byTransfer.set(entry.transferId, [entry]);
1389
+ else mine.push(entry);
1390
+ }
1391
+ return byTransfer;
1392
+ }
1393
+ async function openRow(entry, journal, decryptPayload) {
1394
+ let payload = null;
1395
+ try {
1396
+ const raw = await decryptPayload(entry.payloadEnvelope);
1397
+ if (raw !== null && typeof raw === "object") payload = raw;
1398
+ } catch {
1399
+ payload = null;
1400
+ }
1401
+ const total = Array.isArray(payload?.direct) ? payload.direct.length + (payload.split !== void 0 ? 1 : 0) : 0;
1402
+ const certified = distinctOpCount(journal);
1403
+ return {
1404
+ transferId: entry.transferId,
1405
+ kind: "open",
1406
+ recipient: typeof payload?.recipient === "string" ? payload.recipient : "",
1407
+ coinId: typeof payload?.coinId === "string" ? payload.coinId : "",
1408
+ amount: typeof payload?.amount === "string" ? payload.amount : "",
1409
+ legs: { certified, total: Math.max(total, certified) },
1410
+ deliveryPending: journal.length > 0,
1411
+ createdAt: entry.createdAt
1412
+ };
1413
+ }
1414
+ function journalOnlyRow(transferId, entries) {
1415
+ const certified = distinctOpCount(entries);
1416
+ return {
1417
+ transferId,
1418
+ kind: "open",
1419
+ recipient: entries[0].recipientPubkey,
1420
+ coinId: "",
1421
+ amount: "",
1422
+ legs: { certified, total: certified },
1423
+ deliveryPending: true,
1424
+ createdAt: 0
1425
+ };
1426
+ }
1427
+ function shortfallRow(entry) {
1428
+ return {
1429
+ transferId: entry.transferId,
1430
+ kind: "shortfall",
1431
+ recipient: entry.recipient,
1432
+ coinId: entry.coinId,
1433
+ amount: entry.remainingAmount,
1434
+ legs: { certified: entry.committedTokenIds.length, total: entry.committedTokenIds.length },
1435
+ deliveryPending: false,
1436
+ createdAt: entry.createdAt
1437
+ };
1438
+ }
1439
+ function distinctOpCount(entries) {
1440
+ return new Set(entries.map((entry) => entry.opIndex)).size;
1441
+ }
1442
+
1228
1443
  // node_modules/@noble/ciphers/utils.js
1229
1444
  function isBytes2(a) {
1230
1445
  return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1;
@@ -3556,6 +3771,21 @@ var PaymentsFacade = class {
3556
3771
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
3557
3772
  this.requests = parts.requests;
3558
3773
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
3774
+ this.heartbeat = new ConvergenceHeartbeat({
3775
+ now: () => this.nowMs(),
3776
+ onTick: () => this.trackTail(this.runConvergencePass())
3777
+ });
3778
+ this.converger = new Converger({
3779
+ machineDeps: this.machineDeps,
3780
+ stores: this.machineStores,
3781
+ delivery: deps.deliveryPort,
3782
+ reconcile: (report) => this.requests.reconcile(report),
3783
+ refreshView: () => this.view.delta(),
3784
+ replayMints: () => this.replayMints(),
3785
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
3786
+ emit: deps.emit,
3787
+ now: () => this.nowMs()
3788
+ });
3559
3789
  }
3560
3790
  view;
3561
3791
  ledger;
@@ -3572,10 +3802,18 @@ var PaymentsFacade = class {
3572
3802
  pendingOps = /* @__PURE__ */ new Set();
3573
3803
  unsubscribers = [];
3574
3804
  started = false;
3805
+ // §7 heartbeat: in-memory timing cell + pass runner (see convergence.ts).
3806
+ heartbeat;
3807
+ converger;
3808
+ /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
3809
+ resumeFlight = new SingleFlight();
3810
+ /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
3811
+ activeMoneyOps = /* @__PURE__ */ new Set();
3575
3812
  // ── lifecycle ──────────────────────────────────────────────────────────────
3576
3813
  async start() {
3577
3814
  if (this.started) return;
3578
3815
  this.started = true;
3816
+ this.heartbeat.start();
3579
3817
  await this.deps.session.start();
3580
3818
  this.unsubscribers.push(
3581
3819
  this.deps.session.subscribeStream("mailbox", () => {
@@ -3588,20 +3826,11 @@ var PaymentsFacade = class {
3588
3826
  this.trackTail(this.requests.drainIncoming());
3589
3827
  })
3590
3828
  );
3591
- this.trackTail(this.runResume());
3592
- this.trackTail(
3593
- this.machineStores.deliveryJournal.replay({
3594
- delivery: this.deps.deliveryPort,
3595
- now: this.deps.now ?? Date.now,
3596
- attention: (transferId, code, detail) => {
3597
- this.deps.emit(
3598
- "transfer:attention",
3599
- detail === void 0 ? { transferId, code } : { transferId, code, detail }
3600
- );
3601
- }
3602
- })
3603
- );
3604
- this.trackTail(this.replayMints());
3829
+ const statusUnsub = this.deps.session.subscribeStatus?.((status) => {
3830
+ if (status === "connected") this.heartbeat.recovered();
3831
+ });
3832
+ if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
3833
+ this.trackTail(this.runConvergencePass());
3605
3834
  this.trackTail(this.seedHeldStates());
3606
3835
  await this.view.fullPull().catch(() => void 0);
3607
3836
  this.receiveLoop.start(this.deps.receivePollMs);
@@ -3609,6 +3838,7 @@ var PaymentsFacade = class {
3609
3838
  /** §7 same-address restart gate: resolves only after in-flight ops settle. */
3610
3839
  async stop() {
3611
3840
  this.started = false;
3841
+ this.heartbeat.stop();
3612
3842
  this.receiveLoop.stop();
3613
3843
  for (const unsubscribe of this.unsubscribers) unsubscribe();
3614
3844
  this.unsubscribers = [];
@@ -3634,6 +3864,10 @@ var PaymentsFacade = class {
3634
3864
  history(page) {
3635
3865
  return this.historyStore.page(page ?? {});
3636
3866
  }
3867
+ /** §4 pending-transfers UI surface — derived on read, never cached (convergence.ts). */
3868
+ pendingTransfers() {
3869
+ return derivePendingTransfers(this.machineStores, this.machineDeps.decryptPayload);
3870
+ }
3637
3871
  // ── money movement ─────────────────────────────────────────────────────────
3638
3872
  send(request) {
3639
3873
  return this.track(this.sendWithPolicy(request));
@@ -3646,6 +3880,22 @@ var PaymentsFacade = class {
3646
3880
  mint(coinId, amount) {
3647
3881
  return this.track(this.mintInner(coinId, amount));
3648
3882
  }
3883
+ // §4 retry verb: coalesces onto the running pass — NEVER re-issue send() (#631/#676).
3884
+ async resumeNow() {
3885
+ if (!this.started) return;
3886
+ await this.track(this.runConvergencePass());
3887
+ }
3888
+ /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
3889
+ runConvergencePass() {
3890
+ return this.resumeFlight.run(async () => {
3891
+ const outcome = await this.converger.convergeOnce();
3892
+ const pending = await this.converger.pendingWork().catch(() => true);
3893
+ this.heartbeat.settle(outcome, pending);
3894
+ });
3895
+ }
3896
+ nowMs() {
3897
+ return (this.deps.now ?? Date.now)();
3898
+ }
3649
3899
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
3650
3900
  async sendWithPolicy(request) {
3651
3901
  const recipient = await this.requireSameNetworkRecipient(request.recipient);
@@ -3695,12 +3945,15 @@ var PaymentsFacade = class {
3695
3945
  return recipient;
3696
3946
  }
3697
3947
  async runAttempt(ctx) {
3948
+ this.activeMoneyOps.add(ctx.transferId);
3698
3949
  try {
3699
3950
  const { deliveryPending } = await this.machine.run(ctx.plan);
3700
3951
  this.settleSuccess(ctx, ctx.sourceIds);
3701
3952
  return { kind: "success", deliveryPending };
3702
3953
  } catch (err) {
3703
- return this.disposeFailedAttempt(ctx, err);
3954
+ return await this.disposeFailedAttempt(ctx, err);
3955
+ } finally {
3956
+ this.activeMoneyOps.delete(ctx.transferId);
3704
3957
  }
3705
3958
  }
3706
3959
  /**
@@ -3709,7 +3962,10 @@ var PaymentsFacade = class {
3709
3962
  * the remainder under a NEW transferId — never the full amount.
3710
3963
  */
3711
3964
  async consumePartial(ctx, disposition, run, attempt) {
3712
- run.pendingAny = run.pendingAny || disposition.deliveryPending;
3965
+ if (disposition.deliveryPending) {
3966
+ run.pendingAny = true;
3967
+ this.heartbeat.arm();
3968
+ }
3713
3969
  const partial = disposition.entry;
3714
3970
  run.delivered.push(...partial.committedTokenIds);
3715
3971
  run.firstPartialId ??= partial.transferId;
@@ -3867,6 +4123,7 @@ var PaymentsFacade = class {
3867
4123
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
3868
4124
  settleKeepOpen(ctx) {
3869
4125
  this.queue.clearExpectedChange(ctx.transferId);
4126
+ this.heartbeat.arm();
3870
4127
  }
3871
4128
  settlePartial(ctx, partial) {
3872
4129
  this.ledger.cancel(ctx.transferId);
@@ -3903,6 +4160,7 @@ var PaymentsFacade = class {
3903
4160
  for (const id of run.settledShortfalls.splice(0)) {
3904
4161
  await this.machineStores.shortfalls.removeByKey(id).catch(() => void 0);
3905
4162
  }
4163
+ if (run.pendingAny) this.heartbeat.arm();
3906
4164
  const result = {
3907
4165
  id: transferId,
3908
4166
  status: run.pendingAny ? "confirmed" : "delivered",
@@ -3947,21 +4205,29 @@ var PaymentsFacade = class {
3947
4205
  if (!/^(?:[0-9a-f]{2})+$/.test(coinId) || amount <= 0n) {
3948
4206
  return { success: false, error: "coinId must be even-length lowercase hex and amount positive" };
3949
4207
  }
3950
- const engine = this.engine();
3951
4208
  const mintId = this.newId();
3952
- const params = this.mintParams(coinId, amount);
4209
+ this.activeMoneyOps.add(mintId);
4210
+ try {
4211
+ return await this.mintUnderJournal(mintId, coinId, amount);
4212
+ } finally {
4213
+ this.activeMoneyOps.delete(mintId);
4214
+ }
4215
+ }
4216
+ async mintUnderJournal(mintId, coinId, amount) {
4217
+ const engine = this.engine();
3953
4218
  const entry = {
3954
4219
  mintId,
3955
4220
  coinId,
3956
4221
  amount: amount.toString(),
3957
4222
  tokenId: "",
3958
- createdAt: (this.deps.now ?? Date.now)()
4223
+ createdAt: this.nowMs()
3959
4224
  };
3960
4225
  await this.machineStores.mintJournal.upsert(entry);
3961
4226
  let token;
3962
4227
  try {
3963
- token = await engine.mint(params, { transferId: mintId });
4228
+ token = await engine.mint(this.mintParams(coinId, amount), { transferId: mintId });
3964
4229
  } catch (err) {
4230
+ this.heartbeat.arm();
3965
4231
  return { success: false, error: messageOf(err) };
3966
4232
  }
3967
4233
  if (token.blob.tokenId !== entry.tokenId) {
@@ -3970,6 +4236,7 @@ var PaymentsFacade = class {
3970
4236
  try {
3971
4237
  await this.finalizeMint(engine, mintId, token, coinId, amount.toString());
3972
4238
  } catch (err) {
4239
+ this.heartbeat.arm();
3973
4240
  return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
3974
4241
  }
3975
4242
  return { success: true, tokenId: token.blob.tokenId };
@@ -3992,18 +4259,22 @@ var PaymentsFacade = class {
3992
4259
  this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
3993
4260
  this.trackTail(this.view.delta());
3994
4261
  }
4262
+ /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */
3995
4263
  async replayMints() {
4264
+ let resolved = 0;
3996
4265
  for (const entry of await this.machineStores.mintJournal.list()) {
4266
+ if (this.activeMoneyOps.has(entry.mintId)) continue;
3997
4267
  try {
3998
- await this.replayMint(entry);
4268
+ if (await this.replayMint(entry)) resolved += 1;
3999
4269
  } catch {
4000
4270
  }
4001
4271
  }
4272
+ return resolved;
4002
4273
  }
4003
4274
  async replayMint(entry) {
4004
4275
  if (entry.tokenId !== "" && await this.tokenInServerInventory(entry.tokenId)) {
4005
4276
  await this.machineStores.mintJournal.removeByKey(entry.mintId);
4006
- return;
4277
+ return true;
4007
4278
  }
4008
4279
  const engine = this.engine();
4009
4280
  if (!supportsDeterministicMint(engine)) {
@@ -4011,7 +4282,7 @@ var PaymentsFacade = class {
4011
4282
  transferId: entry.mintId,
4012
4283
  code: ATTENTION_MINT_UNRESOLVED
4013
4284
  });
4014
- return;
4285
+ return false;
4015
4286
  }
4016
4287
  const token = await engine.mint(this.mintParams(entry.coinId, BigInt(entry.amount)), {
4017
4288
  transferId: entry.mintId
@@ -4020,6 +4291,7 @@ var PaymentsFacade = class {
4020
4291
  await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
4021
4292
  }
4022
4293
  await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount);
4294
+ return true;
4023
4295
  }
4024
4296
  mintParams(coinId, amount) {
4025
4297
  return {
@@ -4045,17 +4317,6 @@ var PaymentsFacade = class {
4045
4317
  newId() {
4046
4318
  return (this.deps.newId ?? randomUUID)();
4047
4319
  }
4048
- async runResume() {
4049
- const report = await resumeAll(this.machineDeps);
4050
- await this.requests.reconcile({
4051
- resumed: report.resumed,
4052
- conflicted: report.conflicted,
4053
- open: report.failed
4054
- });
4055
- if (report.resumed.length > 0 || report.conflicted.length > 0) {
4056
- await this.view.delta().catch(() => void 0);
4057
- }
4058
- }
4059
4320
  async seedHeldStates() {
4060
4321
  let page = await this.deps.storagePort.listInventory();
4061
4322
  for (let i = 0; i < INVENTORY_SCAN_PAGE_LIMIT; i++) {
@@ -4069,7 +4330,7 @@ var PaymentsFacade = class {
4069
4330
  toUiToken(tokenId, coinId, amount) {
4070
4331
  const registry = this.deps.registry;
4071
4332
  const iconUrl = registry.getIconUrl(coinId);
4072
- const now = (this.deps.now ?? Date.now)();
4333
+ const now = this.nowMs();
4073
4334
  return {
4074
4335
  id: tokenId,
4075
4336
  coinId,
@@ -4120,6 +4381,8 @@ function messageOf(err) {
4120
4381
  }
4121
4382
  export {
4122
4383
  ATTENTION_MINT_UNRESOLVED,
4384
+ HEARTBEAT_CAP_MS,
4385
+ HEARTBEAT_SEED_MS,
4123
4386
  MAX_RESELECT,
4124
4387
  PaymentsFacade,
4125
4388
  STORE_KEYS,