@dvmkit/sdk 0.11.0-rc.1 → 0.12.0-rc.1

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.
@@ -17,10 +17,10 @@ import {
17
17
  } from "./chunk-E7ZBCT5U.js";
18
18
  import {
19
19
  MemoryCreditLedger
20
- } from "./chunk-55DVLBCD.js";
20
+ } from "./chunk-POYUX2NN.js";
21
21
  import {
22
22
  PostgresJobStore
23
- } from "./chunk-HQPQ4PFI.js";
23
+ } from "./chunk-TWKQFIOW.js";
24
24
  import {
25
25
  SIGNED_ENVELOPE_FIELDS,
26
26
  SignedRequestError,
@@ -133,7 +133,7 @@ import {
133
133
  isNonChannelBitcoinRail,
134
134
  netOwedSats,
135
135
  x402SettlementPending
136
- } from "./chunk-K2ZNIQZP.js";
136
+ } from "./chunk-2VGMZ7VF.js";
137
137
  import {
138
138
  parseStoreValue
139
139
  } from "./chunk-YG7G4DPZ.js";
@@ -995,6 +995,7 @@ function attachCreditMenu(body, menu) {
995
995
  }
996
996
  function toCreditTerms(menu) {
997
997
  return {
998
+ ...menu.balance_payment && { balance_payment: true },
998
999
  min_micro: menu.min_micro,
999
1000
  max_micro: menu.max_micro,
1000
1001
  ttl_ms: menu.ttl_ms,
@@ -12640,6 +12641,7 @@ function buildContext(job, opts) {
12640
12641
  const paymentRequestContent = {
12641
12642
  amount_msats: amountMsats,
12642
12643
  reason,
12644
+ ...pendingFiat && { credit: { amount_micro: pendingFiat.amountMicro, currency: pendingFiat.currency } },
12643
12645
  ...fiatAmount && { fiat_amount: fiatAmount },
12644
12646
  ...payOpts?.mints && { mints: payOpts.mints },
12645
12647
  ...payOpts?.minLocktimeSeconds && { min_locktime_seconds: payOpts.minLocktimeSeconds },
@@ -12718,8 +12720,16 @@ function buildContext(job, opts) {
12718
12720
  if (job.replayPaymentIndex < payments.length) {
12719
12721
  const payment = payments[job.replayPaymentIndex];
12720
12722
  job.replayPaymentIndex++;
12721
- if (job.reactivationPaymentApplied !== true) {
12722
- throw new Error("[internal_error] payment replay reached without credit");
12723
+ const balance = payment.content.credit;
12724
+ const accepted = balance ? await opts.acceptedBalancePayment?.(payment.seq, pendingFiat?.amountMicro, pendingFiat?.currency) : job.reactivationPaymentApplied === true;
12725
+ if (!accepted) throw new Error("[internal_error] payment replay reached without credit");
12726
+ if (balance) {
12727
+ job.pendingPaymentMsats = void 0;
12728
+ job.pendingPaymentFiatMicro = void 0;
12729
+ job.pendingPaymentFiatCurrency = void 0;
12730
+ job.pendingMppChallengeIds = void 0;
12731
+ job.pendingX402Nonce = void 0;
12732
+ job.pendingX402AmountUsdcMicro = void 0;
12723
12733
  }
12724
12734
  job.status = "processing";
12725
12735
  creditedToHandlerMsats += amountMsats;
@@ -12788,7 +12798,7 @@ function buildContext(job, opts) {
12788
12798
  try {
12789
12799
  const counters = await getCounters();
12790
12800
  if (!counters || resolved) return;
12791
- const satisfied = poolBasis ? counters.paidMsats - paidMsatsAtYield >= amountMsats : counters.paidMsats - creditedToHandlerMsats >= amountMsats;
12801
+ const satisfied = counters.balancePaymentSatisfied === true || (poolBasis ? counters.paidMsats - paidMsatsAtYield >= amountMsats : counters.paidMsats - creditedToHandlerMsats >= amountMsats);
12792
12802
  if (satisfied) {
12793
12803
  const resume = async () => {
12794
12804
  job.paidMsats = counters.paidMsats;
@@ -13292,6 +13302,9 @@ var JobManager = class {
13292
13302
  statusChecksInFlight = /* @__PURE__ */ new Set();
13293
13303
  /** Job ids that were notified mid-re-read and must be re-checked. */
13294
13304
  statusChecksQueued = /* @__PURE__ */ new Set();
13305
+ // Bind live resolvers and recovery scaffolds to the store-allocated ask identity.
13306
+ paymentAskSeqs = /* @__PURE__ */ new WeakMap();
13307
+ reactivationAskSeqs = /* @__PURE__ */ new WeakMap();
13295
13308
  // Every local input resume holds this queue through its durable wait update.
13296
13309
  // A prompt response and a payment NOTIFY must not mistake each other for a
13297
13310
  // different machine's successful claim.
@@ -13718,7 +13731,59 @@ var JobManager = class {
13718
13731
  }
13719
13732
  async claimLocalInput(job) {
13720
13733
  await job.messageAppenderTail;
13721
- return isStreamableJobStore(this.jobStore) ? this.jobStore.claimForProcessing(job.id) : true;
13734
+ const askSeq = job.pendingPayment ? this.paymentAskSeqs.get(job.pendingPayment) : void 0;
13735
+ if (job.pendingPayment && this.opts.db && this.jobStore instanceof PostgresJobStore && askSeq === void 0) return false;
13736
+ return this.claimInputForAsk(job.id, askSeq);
13737
+ }
13738
+ async claimInputForAsk(jobId, askSeq) {
13739
+ if (!isStreamableJobStore(this.jobStore)) return true;
13740
+ if (askSeq === void 0 || !this.opts.db || !(this.jobStore instanceof PostgresJobStore)) return this.jobStore.claimForProcessing(jobId);
13741
+ const tx = await this.opts.db.connect();
13742
+ try {
13743
+ await tx.query("BEGIN");
13744
+ const { rows: [record] } = await tx.query("SELECT status FROM jobs WHERE id=$1 FOR UPDATE", [jobId]);
13745
+ const { rows: [ask] } = await tx.query(
13746
+ `SELECT MAX(seq) AS seq FROM job_messages WHERE job_id=$1 AND status='verified'
13747
+ AND data->>'from'='provider' AND data->>'type'='payment-request'`,
13748
+ [jobId]
13749
+ );
13750
+ if (record?.status !== "awaiting-input" || Number(ask?.seq) !== askSeq) {
13751
+ await tx.query("ROLLBACK");
13752
+ return false;
13753
+ }
13754
+ await tx.query(
13755
+ `UPDATE jobs SET status='processing', last_activity_at=$2,
13756
+ caller_wait_ms=caller_wait_ms+GREATEST(0,$2-COALESCE(awaiting_input_at_ms,last_activity_at)),
13757
+ awaiting_input_at_ms=NULL WHERE id=$1`,
13758
+ [jobId, Date.now()]
13759
+ );
13760
+ await tx.query("COMMIT");
13761
+ return true;
13762
+ } catch (error) {
13763
+ await tx.query("ROLLBACK").catch(() => void 0);
13764
+ throw error;
13765
+ } finally {
13766
+ tx.release();
13767
+ }
13768
+ }
13769
+ async adoptPaymentMetadata(job, pending) {
13770
+ const record = await this.jobStore.get(job.id);
13771
+ if (!record || job.pendingPayment !== pending || isTerminal(job.status) || isTerminal(record.status)) return false;
13772
+ job.paidMsats = record.paidMsats;
13773
+ job.pendingPaymentMsats = record.pendingPaymentMsats;
13774
+ job.creditId = record.creditId;
13775
+ job.drawId = record.drawId;
13776
+ job.paymentRail = record.paymentRail;
13777
+ job.paymentMint = record.paymentMint;
13778
+ job.paymentTxHash = record.paymentTxHash;
13779
+ job.nativeAmount = record.nativeAmount;
13780
+ job.nativeAsset = record.nativeAsset;
13781
+ job.pendingPaymentFiatMicro = record.pendingPaymentFiatMicro;
13782
+ job.pendingPaymentFiatCurrency = record.pendingPaymentFiatCurrency;
13783
+ job.pendingMppChallengeIds = record.pendingMppChallengeIds;
13784
+ job.pendingX402Nonce = record.pendingX402Nonce;
13785
+ job.pendingX402AmountUsdcMicro = record.pendingX402AmountUsdcMicro;
13786
+ return true;
13722
13787
  }
13723
13788
  async resumeClaimedPayment(job, resume) {
13724
13789
  const retry = async () => {
@@ -14083,8 +14148,9 @@ var JobManager = class {
14083
14148
  const jobId = job.id;
14084
14149
  job.messageAppender = (msg, opts) => {
14085
14150
  const prev = job.messageAppenderTail ?? Promise.resolve();
14086
- const next = prev.then(
14087
- () => store.appendOutgoing(
14151
+ const next = prev.then(() => {
14152
+ const pending = job.pendingPayment;
14153
+ return store.appendOutgoing(
14088
14154
  jobId,
14089
14155
  {
14090
14156
  from: msg.from,
@@ -14093,7 +14159,11 @@ var JobManager = class {
14093
14159
  content: msg.content
14094
14160
  },
14095
14161
  opts
14096
- ).catch((_err) => {
14162
+ ).then((stored) => {
14163
+ if (msg.type === "payment-request" && pending && job.pendingPayment === pending) {
14164
+ this.paymentAskSeqs.set(pending, stored);
14165
+ }
14166
+ }).catch((_err) => {
14097
14167
  diagnosticConsole.error(
14098
14168
  {
14099
14169
  level: "job_message_append_failed",
@@ -14107,8 +14177,8 @@ var JobManager = class {
14107
14177
  error: void 0
14108
14178
  }
14109
14179
  );
14110
- })
14111
- );
14180
+ });
14181
+ });
14112
14182
  job.messageAppenderTail = next;
14113
14183
  };
14114
14184
  }
@@ -14839,10 +14909,13 @@ var JobManager = class {
14839
14909
  async claimAwaitingInputForReactivation(jobId) {
14840
14910
  if (!isStreamableJobStore(this.jobStore)) return true;
14841
14911
  const store = this.jobStore;
14912
+ const scaffold = this._activeJobs.get(jobId);
14913
+ const askSeq = scaffold ? this.reactivationAskSeqs.get(scaffold) : void 0;
14842
14914
  const deadline = this.monotonicNowMs() + this.reactivationClaimGraceMs;
14843
14915
  for (; ; ) {
14844
14916
  if (this.monotonicNowMs() >= deadline) {
14845
- return store.claimForProcessing(jobId);
14917
+ if (scaffold && this._activeJobs.get(jobId) !== scaffold) return false;
14918
+ return this.claimInputForAsk(jobId, askSeq);
14846
14919
  }
14847
14920
  const record = await store.get(jobId);
14848
14921
  if (record?.status !== "awaiting-input") return false;
@@ -14952,6 +15025,8 @@ var JobManager = class {
14952
15025
  }
14953
15026
  }
14954
15027
  const job = fromJobRecord(record);
15028
+ const ask = record.messages.findLast((message) => message.from === "provider" && message.type === "payment-request");
15029
+ if (ask) this.reactivationAskSeqs.set(job, ask.seq);
14955
15030
  this._activeJobs.set(job.id, job);
14956
15031
  this.wireMessageAppender(job);
14957
15032
  this.watchDurableStatus(job);
@@ -15369,7 +15444,23 @@ var JobManager = class {
15369
15444
  onYield: () => this.persistJob(job),
15370
15445
  onPaymentResume: () => retryJobSnapshot(() => this.persistJob(job)),
15371
15446
  subscribeNotifications: isStreamableJobStore(jobStore) ? (cb) => jobStore.subscribeNotifications(job.id, cb) : void 0,
15372
- getCounters: isStreamableJobStore(jobStore) ? () => jobStore.getCounters(job.id) : void 0,
15447
+ acceptedBalancePayment: this.opts.db && jobStore instanceof PostgresJobStore ? async (seq, amountMicro, currency) => {
15448
+ const { rowCount } = await this.opts.db.query(
15449
+ `SELECT 1 FROM credit_balance_payments p
15450
+ JOIN job_messages m ON m.job_id=p.job_id AND m.seq=(p.response->>'seq')::bigint AND m.status='verified'
15451
+ JOIN job_messages a ON a.job_id=p.job_id AND a.seq=p.request_seq AND a.status='verified'
15452
+ WHERE p.job_id=$1 AND m.seq=$2 AND a.data->>'type'='payment-request'
15453
+ AND (m.data->'content'->'credit'->>'request_seq')::bigint=p.request_seq
15454
+ AND a.data->'content'->'credit'->>'amount_micro'=$3
15455
+ AND a.data->'content'->'credit'->>'currency'=$4`,
15456
+ [job.id, seq, amountMicro === void 0 ? null : String(amountMicro), currency ?? null]
15457
+ );
15458
+ return rowCount === 1;
15459
+ } : void 0,
15460
+ getCounters: isStreamableJobStore(jobStore) ? async () => {
15461
+ await job.messageAppenderTail;
15462
+ return jobStore.getCounters(job.id);
15463
+ } : void 0,
15373
15464
  drawPool: this.opts.creditLedger ? () => this.resolveDrawPool(job) : void 0,
15374
15465
  pricingCurrency: this.pricingCurrency,
15375
15466
  claimForProcessing: isStreamableJobStore(jobStore) ? (resume) => {
@@ -15378,10 +15469,13 @@ var JobManager = class {
15378
15469
  if (!pending || job.pendingPayment !== pending || isTerminal(job.status)) return false;
15379
15470
  if (!await this.claimLocalInput(job)) return false;
15380
15471
  if (job.pendingPayment !== pending || isTerminal(job.status)) return false;
15472
+ let resumed = false;
15381
15473
  await this.resumeClaimedPayment(job, async () => {
15474
+ if (!await this.adoptPaymentMetadata(job, pending)) return;
15382
15475
  await resume?.();
15476
+ resumed = true;
15383
15477
  });
15384
- return true;
15478
+ return resumed;
15385
15479
  });
15386
15480
  } : void 0,
15387
15481
  onSuperseded: () => {
@@ -15414,6 +15508,12 @@ var JobManager = class {
15414
15508
  * fall back on is a slice of the credit's rail value at a ratio pinned
15415
15509
  * whenever that credit was funded, so it cannot safely fund a new ask.
15416
15510
  */
15511
+ async hasAcceptedBalancePayment(jobId) {
15512
+ return this.jobStore instanceof PostgresJobStore && !!this.opts.db && !!(await this.opts.db.query(
15513
+ "SELECT 1 FROM credit_balance_payments WHERE job_id=$1 LIMIT 1",
15514
+ [jobId]
15515
+ )).rowCount;
15516
+ }
15417
15517
  async resolveDrawPool(job) {
15418
15518
  const ledger = this.opts.creditLedger;
15419
15519
  if (!ledger || !job.creditId || !job.drawId) return { basis: "rail" };
@@ -15433,8 +15533,10 @@ var JobManager = class {
15433
15533
  return { basis: "unpriced" };
15434
15534
  }
15435
15535
  const { rail, drawMsats, creditCurrency } = draw.railValue;
15436
- if (!rail || drawMsats <= 0)
15437
- return { basis: "rail", creditCurrency: creditCurrency ?? void 0 };
15536
+ if (!rail || drawMsats <= 0) {
15537
+ const acceptedBalance = await this.hasAcceptedBalancePayment(job.id);
15538
+ if (!acceptedBalance) return { basis: "rail", creditCurrency: creditCurrency ?? void 0 };
15539
+ }
15438
15540
  if (!creditCurrency) {
15439
15541
  this.warnDrawPool(job, "credit_currency_missing");
15440
15542
  return { basis: "unpriced" };
@@ -15874,7 +15976,7 @@ var JobManager = class {
15874
15976
  const onJobCompleted = this.opts.onJobCompleted;
15875
15977
  const onRevenueSkippedNoRail = this.opts.onRevenueSkippedNoRail;
15876
15978
  if (!onJobCompleted && !onRevenueSkippedNoRail) return false;
15877
- if (job.status !== "completed" || job.paidMsats <= 0) return false;
15979
+ if (job.status !== "completed") return false;
15878
15980
  let credit = {};
15879
15981
  let creditRail;
15880
15982
  let drawAmountMicro;
@@ -15911,6 +16013,7 @@ var JobManager = class {
15911
16013
  settledAt: draw.resolvedAt ?? void 0
15912
16014
  };
15913
16015
  }
16016
+ if (job.paidMsats <= 0 && !(drawAmountMicro !== void 0 && drawAmountMicro > 0 && typeof credit.creditCurrency === "string" && creditRail && isFundingRail(creditRail))) return false;
15914
16017
  if (!job.paymentRail) {
15915
16018
  if (job.creditId && job.drawId) {
15916
16019
  const creditCurrency = typeof credit.creditCurrency === "string" ? credit.creditCurrency : void 0;
@@ -17186,13 +17289,167 @@ function withWallClockDeadline(promise, timeoutMs) {
17186
17289
  });
17187
17290
  }
17188
17291
 
17292
+ // src/sdk/server/balance-payment.ts
17293
+ import { createHash as createHash9 } from "crypto";
17294
+ import { z as z2 } from "zod";
17295
+ var BALANCE_PAYMENT_SCHEMA = z2.object({
17296
+ credit_id: z2.string().min(1).max(256),
17297
+ draw_id: z2.string().min(1).max(256),
17298
+ payment_id: z2.string().min(1).max(256),
17299
+ request_seq: z2.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
17300
+ amount_micro: z2.number().int().positive().max(Number.MAX_SAFE_INTEGER),
17301
+ currency: z2.string().regex(/^[a-z]{3}$/),
17302
+ pubkey: z2.string(),
17303
+ signature: z2.string(),
17304
+ timestamp: z2.number(),
17305
+ nonce: z2.string()
17306
+ });
17307
+ var BalancePaymentError = class extends Error {
17308
+ constructor(status, body) {
17309
+ super(String(body.error));
17310
+ this.status = status;
17311
+ this.body = body;
17312
+ }
17313
+ status;
17314
+ body;
17315
+ };
17316
+ function conflict(error = "credit_payment_conflict") {
17317
+ throw new BalancePaymentError(409, { error });
17318
+ }
17319
+ async function commitBalancePayment(args) {
17320
+ args.auth.recheckSignature(BALANCE_PAYMENT_SCHEMA, args.raw, args.domain);
17321
+ const auth = BALANCE_PAYMENT_SCHEMA.parse(args.raw);
17322
+ const { pubkey, credit_id, draw_id, payment_id, request_seq, amount_micro, currency } = auth;
17323
+ const fingerprint = createHash9("sha256").update(JSON.stringify([
17324
+ args.domain,
17325
+ args.jobId,
17326
+ pubkey,
17327
+ credit_id,
17328
+ draw_id,
17329
+ payment_id,
17330
+ request_seq,
17331
+ amount_micro,
17332
+ currency
17333
+ ])).digest("hex");
17334
+ const tx = await args.pool.connect();
17335
+ try {
17336
+ await tx.query("BEGIN");
17337
+ await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`balance-payment:${payment_id}`]);
17338
+ const prior = await tx.query(
17339
+ "SELECT fingerprint, response FROM credit_balance_payments WHERE payment_id=$1",
17340
+ [payment_id]
17341
+ );
17342
+ if (prior.rows[0]) {
17343
+ if (prior.rows[0].fingerprint !== fingerprint) conflict();
17344
+ await tx.query("COMMIT");
17345
+ return { response: prior.rows[0].response, replayed: true };
17346
+ }
17347
+ if (args.allowFresh === false) conflict("balance_payment_unavailable");
17348
+ args.auth.checkAuth(BALANCE_PAYMENT_SCHEMA, args.raw, args.domain);
17349
+ const jobs = await tx.query("SELECT * FROM jobs WHERE id=$1 FOR UPDATE", [args.jobId]);
17350
+ const job = jobs.rows[0];
17351
+ if (!job || job.dvm_id !== null && job.dvm_id !== args.dvmId || job.requester_pubkey !== pubkey) conflict("credit_payment_owner_mismatch");
17352
+ if (["completed", "failed", "cancelled"].includes(job.status)) conflict("job_not_active");
17353
+ const asks = await tx.query(
17354
+ `SELECT data FROM job_messages WHERE job_id=$1 AND data->>'type'='payment-request'
17355
+ AND data->>'from'='provider' AND status='verified' ORDER BY seq DESC LIMIT 1`,
17356
+ [args.jobId]
17357
+ );
17358
+ const ask = asks.rows[0]?.data;
17359
+ if (!ask || ask.seq !== request_seq || !ask.content.credit || ask.content.credit.amount_micro !== amount_micro || ask.content.credit.currency !== currency || Number(job.pending_payment_fiat_micro) !== amount_micro || job.pending_payment_fiat_currency !== currency || Number(job.pending_payment_msats) <= 0 || job.asked_topup_micro === null || Number(job.asked_topup_micro) < amount_micro) {
17360
+ conflict("credit_payment_ask_mismatch");
17361
+ }
17362
+ if ((job.credit_id !== null || job.draw_id !== null) && (job.credit_id !== credit_id || job.draw_id !== draw_id)) conflict();
17363
+ const { rows: [credit] } = await tx.query(
17364
+ "SELECT caller_pubkey,currency FROM credits WHERE credit_id=$1",
17365
+ [credit_id]
17366
+ );
17367
+ if (!credit || credit.caller_pubkey !== pubkey) conflict("credit_payment_owner_mismatch");
17368
+ if (credit.currency !== currency) conflict("credit_payment_currency_mismatch");
17369
+ const seq = Number(job.next_seq);
17370
+ const message = { seq, from: "requester", type: "payment", timestamp: Math.floor(Date.now() / 1e3), content: { credit: args.raw } };
17371
+ await tx.query(`INSERT INTO job_messages (job_id,seq,data,status,created_at_ms)
17372
+ VALUES ($1,$2,$3,'pending-verification',$4)`, [args.jobId, seq, JSON.stringify(message), Date.now()]);
17373
+ if (job.credit_id === null) {
17374
+ const existing = await tx.query("SELECT 1 FROM credit_draws WHERE credit_id=$1 AND draw_id=$2", [credit_id, draw_id]);
17375
+ if (existing.rowCount) conflict();
17376
+ await args.ledger.draw({
17377
+ creditId: credit_id,
17378
+ drawId: draw_id,
17379
+ amountMicro: 0,
17380
+ jobId: args.jobId,
17381
+ hostingPaymentSeq: seq,
17382
+ tx
17383
+ });
17384
+ }
17385
+ const grown = await args.ledger.growDraw({
17386
+ creditId: credit_id,
17387
+ drawId: draw_id,
17388
+ jobId: args.jobId,
17389
+ spendExistingBalance: true,
17390
+ hostingPaymentSeq: seq,
17391
+ addAmountMicro: amount_micro,
17392
+ capMicro: Number(job.asked_topup_micro),
17393
+ tx
17394
+ });
17395
+ if (grown.addedAmountMicro !== amount_micro) conflict("credit_payment_ask_mismatch");
17396
+ const basis = grown.railValue;
17397
+ const nativeAsset = basis.nativeAsset;
17398
+ await tx.query(
17399
+ `UPDATE jobs SET next_seq=next_seq+1,
17400
+ paid_msats=paid_msats+$2, pending_payment_msats=NULL,
17401
+ pending_payment_fiat_micro=NULL, pending_payment_fiat_currency=NULL,
17402
+ pending_mpp_challenge_ids=NULL, pending_x402_nonce=NULL, pending_x402_amount_usdc_micro=NULL,
17403
+ credit_id=$3, draw_id=$4, payment_rail=$5, payment_mint=$6, payment_tx_hash=$7,
17404
+ native_amount=$8, native_asset=$9, last_activity_at=$10 WHERE id=$1`,
17405
+ [
17406
+ args.jobId,
17407
+ grown.addedRailValue.drawMsats,
17408
+ credit_id,
17409
+ draw_id,
17410
+ basis.rail,
17411
+ basis.mint,
17412
+ drawSettlementRef(args.dvmId, credit_id, draw_id),
17413
+ basis.drawNative,
17414
+ nativeAsset,
17415
+ Date.now()
17416
+ ]
17417
+ );
17418
+ await tx.query("UPDATE job_messages SET status='verified' WHERE job_id=$1 AND seq=$2", [args.jobId, seq]);
17419
+ const response = { seq };
17420
+ await tx.query(
17421
+ "INSERT INTO credit_balance_payments(payment_id,fingerprint,response,job_id,request_seq) VALUES($1,$2,$3,$4,$5)",
17422
+ [payment_id, fingerprint, JSON.stringify(response), args.jobId, request_seq]
17423
+ );
17424
+ await tx.query("SELECT pg_notify('job_messages', $1)", [args.jobId]);
17425
+ await tx.query("COMMIT");
17426
+ return { response, replayed: false };
17427
+ } catch (error) {
17428
+ await tx.query("ROLLBACK").catch(() => void 0);
17429
+ if (error instanceof CreditLedgerError && error.code === "insufficient_credit") {
17430
+ const remaining = error.details.availableMicro ?? 0;
17431
+ throw new BalancePaymentError(402, {
17432
+ error: "insufficient_credit",
17433
+ remaining_micro: remaining,
17434
+ shortfall_micro: Math.max(0, auth.amount_micro - remaining)
17435
+ });
17436
+ }
17437
+ if (error instanceof CreditLedgerError) {
17438
+ throw new BalancePaymentError(409, { error: error.code });
17439
+ }
17440
+ throw error;
17441
+ } finally {
17442
+ tx.release();
17443
+ }
17444
+ }
17445
+
17189
17446
  // src/sdk/server/app.ts
17190
- import { createHash as createHash10, randomBytes as randomBytes5, randomUUID as randomUUID6 } from "crypto";
17447
+ import { createHash as createHash11, randomBytes as randomBytes5, randomUUID as randomUUID6 } from "crypto";
17191
17448
  import { Hono } from "hono";
17192
17449
  import { getCookie } from "hono/cookie";
17193
17450
  import { cors } from "hono/cors";
17194
17451
  import { streamSSE } from "hono/streaming";
17195
- import { toJSONSchema, z as z2 } from "zod";
17452
+ import { toJSONSchema, z as z3 } from "zod";
17196
17453
 
17197
17454
  // src/lib/git-sha.ts
17198
17455
  var DIRTY_SUFFIX = "-dirty";
@@ -20238,7 +20495,7 @@ initSchemaForm();
20238
20495
  }
20239
20496
 
20240
20497
  // src/sdk/server/request-fingerprint.ts
20241
- import { createHash as createHash9 } from "crypto";
20498
+ import { createHash as createHash10 } from "crypto";
20242
20499
  function requestFingerprint(req, opts) {
20243
20500
  const canonical = canonicalize({
20244
20501
  capability: req.capability,
@@ -20246,7 +20503,7 @@ function requestFingerprint(req, opts) {
20246
20503
  params: req.params ?? {},
20247
20504
  data: opts.stripAuthEnvelope ? stripEnvelope(req.data) : req.data
20248
20505
  });
20249
- return createHash9("sha256").update(canonical).digest("hex");
20506
+ return createHash10("sha256").update(canonical).digest("hex");
20250
20507
  }
20251
20508
  var ENVELOPE_KEYS = /* @__PURE__ */ new Set(["pubkey", "signature", "timestamp", "nonce", "auth_statement"]);
20252
20509
  function stripEnvelope(data) {
@@ -21292,7 +21549,7 @@ var DVMServer = class {
21292
21549
  const dvmId = this.opts.dvmId ?? `dev:${this.descriptor.name}`;
21293
21550
  return {
21294
21551
  dvm_id: dvmId,
21295
- builder_pubkey: createHash10("sha256").update(`dvmkit-dev:${dvmId}`).digest("hex")
21552
+ builder_pubkey: createHash11("sha256").update(`dvmkit-dev:${dvmId}`).digest("hex")
21296
21553
  };
21297
21554
  }
21298
21555
  throw new AuthAudienceError();
@@ -21573,6 +21830,9 @@ var DVMServer = class {
21573
21830
  creditTtlMs: this.descriptor.credit ? this.descriptor.credit.ttlSeconds * 1e3 : void 0
21574
21831
  };
21575
21832
  }
21833
+ balancePaymentSupported(includeDisabled = false) {
21834
+ return (includeDisabled || this.opts.env.DVMKIT_BALANCE_PAYMENTS !== "false") && !!this.descriptor.credit && !!this.opts.db && this.creditLedger instanceof CreditLedger && this.jobManager.store instanceof PostgresJobStore && !!this.authAudience && !!this.descriptor.auth;
21835
+ }
21576
21836
  /**
21577
21837
  * Assemble this DVM's funding menu for a response denominated in
21578
21838
  * `currency`. All the refusal logic — including the credits-spec
@@ -21583,7 +21843,7 @@ var DVMServer = class {
21583
21843
  async creditMenu(currency, callerPubkey) {
21584
21844
  await this.ensureTempoSessionHealth();
21585
21845
  const mppSplit = this.creditMppSplit();
21586
- return buildCreditMenu({
21846
+ const menu = await buildCreditMenu({
21587
21847
  runtime: "container",
21588
21848
  config: this.descriptor.credit,
21589
21849
  currency,
@@ -21602,6 +21862,8 @@ var DVMServer = class {
21602
21862
  devMode: this.opts.devMode,
21603
21863
  callerPubkey
21604
21864
  });
21865
+ if (menu && this.balancePaymentSupported()) menu.balance_payment = true;
21866
+ return menu;
21605
21867
  }
21606
21868
  async handleCredit(c) {
21607
21869
  let hostingFundingAdmissionId;
@@ -21640,7 +21902,7 @@ var DVMServer = class {
21640
21902
  const paymentAuthorization = ctx.req.header("Authorization");
21641
21903
  const mppArtifact = paymentAuthorization ? (await import("mppx")).Credential.extractPaymentScheme(paymentAuthorization) : void 0;
21642
21904
  const attachedPayment = ctx.req.header("X-Cashu") ?? ctx.req.header("PAYMENT-SIGNATURE") ?? ctx.req.header("X-PAYMENT") ?? mppArtifact;
21643
- const paymentArtifactHash = attachedPayment ? createHash10("sha256").update(attachedPayment).digest("hex") : void 0;
21905
+ const paymentArtifactHash = attachedPayment ? createHash11("sha256").update(attachedPayment).digest("hex") : void 0;
21644
21906
  hostingFundingArtifactHash = paymentArtifactHash;
21645
21907
  if (!await control.admit(admissionId, workId, {
21646
21908
  creditId,
@@ -21943,7 +22205,7 @@ var DVMServer = class {
21943
22205
  );
21944
22206
  } catch (err) {
21945
22207
  const schemaUrl = infoSchemaUrl(c.req.url, this.publicOrigin);
21946
- const { message, hint } = err instanceof z2.ZodError ? humanizeZodError(err, { schemaUrl }) : {
22208
+ const { message, hint } = err instanceof z3.ZodError ? humanizeZodError(err, { schemaUrl }) : {
21947
22209
  message: "Quote-input validation failed",
21948
22210
  hint: `Inspect ${schemaUrl} for the expected input schema.`
21949
22211
  };
@@ -22349,7 +22611,7 @@ var DVMServer = class {
22349
22611
  const authorization = c.req.header("Authorization");
22350
22612
  const mppArtifact = authorization ? (await import("mppx")).Credential.extractPaymentScheme(authorization) : void 0;
22351
22613
  const attachedPayment = c.req.header("X-Cashu") ?? c.req.header("PAYMENT-SIGNATURE") ?? c.req.header("X-PAYMENT") ?? mppArtifact;
22352
- const hostingPaymentArtifactHash = attachedPayment ? createHash10("sha256").update(attachedPayment).digest("hex") : void 0;
22614
+ const hostingPaymentArtifactHash = attachedPayment ? createHash11("sha256").update(attachedPayment).digest("hex") : void 0;
22353
22615
  if (!resumeOnly && this.opts.hostingControlInstance && hostingPaymentArtifactHash) {
22354
22616
  const priorId = await this.opts.hostingControlInstance.acceptedJobByPaymentArtifactHash(
22355
22617
  hostingPaymentArtifactHash
@@ -22647,7 +22909,7 @@ var DVMServer = class {
22647
22909
  }
22648
22910
  const isAnonymous = requesterId === "anonymous" || requesterId === "dev-anonymous";
22649
22911
  const jobToken = isAnonymous ? randomBytes5(32).toString("hex") : void 0;
22650
- const requesterTokenHash = jobToken ? createHash10("sha256").update(jobToken).digest("hex") : void 0;
22912
+ const requesterTokenHash = jobToken ? createHash11("sha256").update(jobToken).digest("hex") : void 0;
22651
22913
  const job = jm.createJob(
22652
22914
  normalisedBody,
22653
22915
  requesterId,
@@ -23143,6 +23405,53 @@ var DVMServer = class {
23143
23405
  const id = c.req.param("id") ?? "";
23144
23406
  const jm = this.jobManager;
23145
23407
  const activeJob = jm.activeJobs.get(id);
23408
+ const wire = await c.req.raw.clone().json().catch(() => void 0);
23409
+ if (wire?.type === "payment" && wire.content && typeof wire.content === "object" && "credit" in wire.content) {
23410
+ const record2 = await jm.store.get(id);
23411
+ if (!record2 || !isJobOwner(c, record2)) return c.json({ error: "Job not found" }, 404);
23412
+ if (Object.keys(wire.content).some((key) => key !== "credit")) {
23413
+ return c.json({ error: "invalid_message", message: "Credit authorization cannot be combined with a rail payment" }, 400);
23414
+ }
23415
+ if (!this.balancePaymentSupported(true) || !this.opts.db || !this.descriptor.auth || !(this.creditLedger instanceof CreditLedger)) {
23416
+ return c.json({ error: "balance_payment_unavailable" }, 409);
23417
+ }
23418
+ try {
23419
+ const result = await commitBalancePayment({
23420
+ pool: this.opts.db,
23421
+ ledger: this.creditLedger,
23422
+ auth: this.descriptor.auth,
23423
+ domain: this.signedRequestDomain(c, null),
23424
+ jobId: id,
23425
+ dvmId: this.opts.dvmId ?? "",
23426
+ raw: wire.content.credit,
23427
+ allowFresh: this.opts.env.DVMKIT_BALANCE_PAYMENTS !== "false"
23428
+ });
23429
+ try {
23430
+ await this.opts.db.query("SELECT pg_notify('job_messages', $1)", [id]);
23431
+ if (!activeJob) {
23432
+ const fresh = await jm.store.get(id);
23433
+ if (fresh?.status === "awaiting-input" && isStreamableJobStore(jm.store)) {
23434
+ fresh.messages = await jm.store.getMessages(id, 0);
23435
+ fresh.seq = Math.max(fresh.seq, ...fresh.messages.map((message) => message.seq));
23436
+ const ask = fresh.messages.findLast((message) => message.from === "provider" && message.type === "payment-request");
23437
+ const authorized = wire.content.credit;
23438
+ if (ask?.seq === authorized.request_seq && !jm.activeJobs.has(id)) {
23439
+ const { job: job2 } = jm.reactivateJob(fresh);
23440
+ const lock = await jm.store.tryReactivationLock(id, () => this.runReactivation(jm, job2, { type: "payment", content: wire.content }, fresh.status));
23441
+ if (!lock.acquired) this.teardownReactivationScaffold(jm, job2);
23442
+ }
23443
+ }
23444
+ }
23445
+ } catch {
23446
+ diagnosticConsole.error({ level: "balance_payment_resume_unavailable", job_id: id });
23447
+ }
23448
+ return c.json(result.response, 201);
23449
+ } catch (error) {
23450
+ if (error instanceof SignedRequestError) return buildAuthErrorResponse(c, error, this.publicOrigin);
23451
+ if (error instanceof BalancePaymentError) return c.json(error.body, error.status);
23452
+ throw error;
23453
+ }
23454
+ }
23146
23455
  if (activeJob) {
23147
23456
  if (!isJobOwner(c, activeJob)) return c.json({ error: "Job not found" }, 404);
23148
23457
  if (isTerminal(activeJob.status)) {
@@ -23212,6 +23521,10 @@ var DVMServer = class {
23212
23521
  }
23213
23522
  const contentErr = validateMessageContent(body.type, body.content, this.opts.devMode === true);
23214
23523
  if (contentErr) return c.json({ error: "invalid_message", message: contentErr }, 400);
23524
+ if (isStreamableJobStore(jm.store)) {
23525
+ record.messages = await jm.store.getMessages(id, 0);
23526
+ record.seq = Math.max(record.seq, ...record.messages.map((message) => message.seq));
23527
+ }
23215
23528
  let job;
23216
23529
  try {
23217
23530
  ({ job } = jm.reactivateJob(record));
@@ -23312,6 +23625,10 @@ var DVMServer = class {
23312
23625
  */
23313
23626
  async runReactivation(jm, job, body, priorStatus) {
23314
23627
  if (priorStatus === "awaiting-input") {
23628
+ if (jm.activeJobs.get(job.id) !== job) {
23629
+ this.teardownReactivationScaffold(jm, job);
23630
+ return;
23631
+ }
23315
23632
  const claimed = await jm.claimAwaitingInputForReactivation(job.id);
23316
23633
  if (!claimed) {
23317
23634
  this.teardownReactivationScaffold(jm, job);
@@ -23341,9 +23658,11 @@ var DVMServer = class {
23341
23658
  * must not persist our stale snapshot; clearing the appender prevents that.
23342
23659
  */
23343
23660
  teardownReactivationScaffold(jm, job) {
23344
- jm.clearIdleTimer(job.id);
23345
- jm.unwatchDurableStatus(job.id);
23346
- jm.activeJobs.delete(job.id);
23661
+ if (jm.activeJobs.get(job.id) === job) {
23662
+ jm.clearIdleTimer(job.id);
23663
+ jm.unwatchDurableStatus(job.id);
23664
+ jm.activeJobs.delete(job.id);
23665
+ }
23347
23666
  job.messageAppender = void 0;
23348
23667
  }
23349
23668
  async handleDeleteJob(c) {
@@ -23393,7 +23712,7 @@ function isJobOwner(c, job) {
23393
23712
  if (job.requesterTokenHash) {
23394
23713
  const token = c.req.header("X-Job-Token");
23395
23714
  if (!token) return false;
23396
- const provided = createHash10("sha256").update(token).digest("hex");
23715
+ const provided = createHash11("sha256").update(token).digest("hex");
23397
23716
  return timingSafeEqualHex(provided, job.requesterTokenHash);
23398
23717
  }
23399
23718
  return c.get("requesterId") === job.requesterId;