@render-foundation/utils 0.0.254 → 0.0.256

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.
@@ -35,7 +35,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
35
35
  return (mod && mod.__esModule) ? mod : { "default": mod };
36
36
  };
37
37
  Object.defineProperty(exports, "__esModule", { value: true });
38
- exports.pgClient = exports.batchFinishedAtMedian = exports.foldGrantAllotment = exports.CREDIT_DECIMALS = exports.EUR_TO_CREDITS = exports.DISPERSED_BURN_CORRECTION_TABLE = exports.DISPERSED_SETTLEMENT_TABLE = exports.GRANT_BURN_TABLE = exports.GRANT_BURN_ID_TABLE = exports.OTOY_GRANT_TABLE = exports.USER_GRANT_SPEND_TABLE = exports.CURRENT_USER_GRANT_SPEND_TABLE = exports.POLYGON_UPGRADE_TABLE = exports.ENTITY_EPOCH_INFO_TABLE = exports.BURN_CORRECTION_TABLE = exports.NETWORK_REVENUE_TABLE = exports.BRIDGE_TRANSFER_TABLE = exports.BURN_TABLE = exports.JOB_TABLE = exports.JOB_ID_TABLE = exports.LIABILITY_ADJUSTMENT_BATCH_TABLE = exports.LIABILITY_ADJUSTMENT_TABLE = exports.LIABILITY_TABLE = exports.SOL_TRANSFER_TABLE = exports.ENTITY_TABLE = exports.MANUAL_BURN_TABLE = exports.EPOCH_TABLE = exports.SOL_TX_TABLE = void 0;
38
+ exports.pgClient = exports.batchFinishedAtMedian = exports.foldGrantWindow = exports.GRANT_LOOKBACK_MS = exports.GRANT_LOOKBACK_DAYS = exports.CREDIT_DECIMALS = exports.GRANT_WINDOW_START = exports.EUR_TO_CREDITS = exports.DISPERSED_BURN_CORRECTION_TABLE = exports.DISPERSED_SETTLEMENT_TABLE = exports.GRANT_BURN_TABLE = exports.GRANT_BURN_ID_TABLE = exports.OTOY_GRANT_TABLE = exports.USER_GRANT_SPEND_TABLE = exports.CURRENT_USER_GRANT_SPEND_TABLE = exports.POLYGON_UPGRADE_TABLE = exports.ENTITY_EPOCH_INFO_TABLE = exports.BURN_CORRECTION_TABLE = exports.NETWORK_REVENUE_TABLE = exports.BRIDGE_TRANSFER_TABLE = exports.BURN_TABLE = exports.JOB_TABLE = exports.JOB_ID_TABLE = exports.LIABILITY_ADJUSTMENT_BATCH_TABLE = exports.LIABILITY_ADJUSTMENT_TABLE = exports.LIABILITY_TABLE = exports.SOL_TRANSFER_TABLE = exports.ENTITY_TABLE = exports.MANUAL_BURN_TABLE = exports.EPOCH_TABLE = exports.SOL_TX_TABLE = void 0;
39
39
  const kysely_1 = require("kysely");
40
40
  const logger_1 = require("../../logger");
41
41
  const pg_1 = require("pg");
@@ -71,36 +71,117 @@ BigInt.prototype.toJSON = function () {
71
71
  // confirmed_amount = EUR, despite the column name); 1 render credit = EUR 0.25, so credits = EUR x 4.
72
72
  // job.render_amt / user_grant_spend.render_spent_delta are credits x 1e8.
73
73
  exports.EUR_TO_CREDITS = BigInt(4);
74
+ // Grant accounting window: otoy_grant mirrors OTOY RFG issuances from this date. Consumption against the
75
+ // window counts EVERY job the user ran in it — buy-and-burn jobs consumed OTOY credits exactly like
76
+ // emissions-funded ones (counting only emissions-attributed spend over-issued ~2.58M credits, 2026-08-04).
77
+ exports.GRANT_WINDOW_START = '2025-05-01';
74
78
  exports.CREDIT_DECIMALS = BigInt(100000000); // 1e8
75
- /**
76
- * Pure core of the grant allotment: fold a user's OTOY grants against what's already been spent.
77
- *
78
- * consumed = attributedSpend (spend already stamped with an otoy_grant_id)
79
- * + correctionSettled (jobs whose grant is already being settled by a burn_correction)
80
- *
81
- * The correctionSettled term is the DOUBLE-SPEND GUARD: an escrow_credit correction means we're
82
- * recovering that job's dollars from escrow, so its grant is already committed. Counting it here stops
83
- * the same grant from also funding a future emissions burn.
84
- *
85
- * fifoGrantId = the oldest grant still holding room, i.e. the one the next spend draws from.
86
- */
87
- const foldGrantAllotment = (grants, attributedSpend, correctionSettled) => {
88
- const granted = grants.reduce((s, g) => s + g.amountCredits, BigInt(0));
89
- const consumed = (attributedSpend > BigInt(0) ? attributedSpend : BigInt(0)) +
90
- (correctionSettled > BigInt(0) ? correctionSettled : BigInt(0));
91
- const avail = granted > consumed ? granted - consumed : BigInt(0);
92
- let drawn = consumed;
79
+ // A grant is consumable for this long after issuance; whatever is left after that EXPIRES (2026-08-05
80
+ // model change). A job can only draw grants issued within the lookback window before it ran.
81
+ exports.GRANT_LOOKBACK_DAYS = 60;
82
+ exports.GRANT_LOOKBACK_MS = exports.GRANT_LOOKBACK_DAYS * 24 * 3600 * 1000;
83
+ const foldGrantWindow = (events, opts = {}) => {
84
+ var _a, _b, _c;
85
+ const lookback = (_a = opts.lookbackMs) !== null && _a !== void 0 ? _a : exports.GRANT_LOOKBACK_MS;
86
+ const now = (_b = opts.now) !== null && _b !== void 0 ? _b : Date.now();
87
+ const sorted = [...events].sort((a, b) => a.t - b.t || (a.amt > b.amt ? -1 : 1)); // credits first on ties
88
+ const buckets = [];
89
+ const jobs = [];
90
+ const remaining = (b) => b.amount - b.reversed - b.drawnEmissions - b.drawnMisrouted;
91
+ let beyond = BigInt(0);
92
+ for (const e of sorted) {
93
+ if (e.src === 'grant') {
94
+ if (e.amt > BigInt(0)) {
95
+ buckets.push({
96
+ grantId: e.grantId,
97
+ t: e.t,
98
+ amount: e.amt,
99
+ reversed: BigInt(0),
100
+ drawnEmissions: BigInt(0),
101
+ drawnMisrouted: BigInt(0),
102
+ expired: BigInt(0),
103
+ left: BigInt(0),
104
+ });
105
+ }
106
+ else {
107
+ let claw = -e.amt; // clawback hits remaining capacity oldest-first; excess is dropped
108
+ for (const b of buckets) {
109
+ if (claw <= BigInt(0))
110
+ break;
111
+ const take = remaining(b) < claw ? remaining(b) : claw;
112
+ if (take > BigInt(0)) {
113
+ b.reversed += take;
114
+ claw -= take;
115
+ }
116
+ }
117
+ }
118
+ }
119
+ else {
120
+ let need = -e.amt;
121
+ const total = need;
122
+ let emissionsLeft = (_c = e.emissions) !== null && _c !== void 0 ? _c : need; // no split provided -> count it all as emissions
123
+ let jobEm = BigInt(0);
124
+ let jobMis = BigInt(0);
125
+ for (const b of buckets) {
126
+ if (need <= BigInt(0))
127
+ break;
128
+ if (b.t + lookback < e.t)
129
+ continue; // grant expired before this job ran
130
+ const take = remaining(b) < need ? remaining(b) : need;
131
+ if (take <= BigInt(0))
132
+ continue;
133
+ const em = emissionsLeft < take ? emissionsLeft : take;
134
+ b.drawnEmissions += em;
135
+ b.drawnMisrouted += take - em;
136
+ jobEm += em;
137
+ jobMis += take - em;
138
+ emissionsLeft -= em;
139
+ need -= take;
140
+ }
141
+ beyond += need;
142
+ jobs.push({
143
+ id: e.id,
144
+ t: e.t,
145
+ need: total,
146
+ drawnEmissions: jobEm,
147
+ drawnMisrouted: jobMis,
148
+ beyond: need,
149
+ });
150
+ }
151
+ }
152
+ let avail = BigInt(0);
153
+ let expired = BigInt(0);
154
+ let usedEmissions = BigInt(0);
155
+ let usedMisrouted = BigInt(0);
93
156
  let fifoGrantId;
94
- for (const g of grants) {
95
- if (drawn < g.amountCredits) {
96
- fifoGrantId = g.id;
97
- break;
157
+ for (const b of buckets) {
158
+ usedEmissions += b.drawnEmissions;
159
+ usedMisrouted += b.drawnMisrouted;
160
+ const rem = remaining(b);
161
+ if (b.t + lookback < now) {
162
+ b.expired = rem;
163
+ }
164
+ else {
165
+ b.left = rem;
166
+ avail += rem;
167
+ if (fifoGrantId === undefined && rem > BigInt(0) && b.grantId !== undefined)
168
+ fifoGrantId = b.grantId;
98
169
  }
99
- drawn -= g.amountCredits;
170
+ expired += b.expired;
100
171
  }
101
- return { granted, consumed, avail, fifoGrantId };
172
+ return {
173
+ avail,
174
+ beyondGrants: beyond,
175
+ jobCharged: usedEmissions + usedMisrouted,
176
+ usedEmissions,
177
+ usedMisrouted,
178
+ expired,
179
+ buckets,
180
+ jobs,
181
+ fifoGrantId,
182
+ };
102
183
  };
103
- exports.foldGrantAllotment = foldGrantAllotment;
184
+ exports.foldGrantWindow = foldGrantWindow;
104
185
  const base_2 = require("./base");
105
186
  const moment_1 = __importDefault(require("moment"));
106
187
  pg.types.setTypeParser(1114, (str) => moment_1.default.utc(str).toDate());
@@ -228,66 +309,54 @@ const pgClient = (config) => {
228
309
  // Spendable grant per user, straight off otoy_grant (FIFO), net of what's already been spent or
229
310
  // already settled by a burn correction. See GrantAllotment for the units + the double-spend guard.
230
311
  const getGrantAllotments = (db, p, log = logger_1.consoleLogger) => __awaiter(void 0, void 0, void 0, function* () {
231
- var _b, _c, _d;
312
+ var _b, _c;
232
313
  const out = {};
233
314
  if (!p.userIds.length)
234
315
  return out;
235
- const grants = yield db
236
- .selectFrom(exports.OTOY_GRANT_TABLE)
237
- .select(['id', 'user_id', 'amount_credits', 'otoy_created_at'])
238
- .where('user_id', 'in', p.userIds)
239
- .orderBy('otoy_created_at', 'asc')
240
- .orderBy('id', 'asc')
241
- .execute();
242
- if (!grants.length)
243
- return out;
244
- // Anything already booked against a pool grant, in EITHER direction:
245
- // - negative delta = spend drawn from the grant (this mechanism)
246
- // - positive delta = the grant was CREDITED into current_user_grant_spend (the one-off canary
247
- // route). That balance is already handed to the caller separately, so leaving it in the pool too
248
- // would hand out the same grant twice.
249
- // Hence abs(): both directions retire pool capacity.
250
- const spent = yield db
251
- .selectFrom(exports.USER_GRANT_SPEND_TABLE)
252
- .select((eb) => [
253
- 'user_id',
254
- eb.fn.sum((0, kysely_1.sql) `abs(render_spent_delta)`).as('net'),
255
- ])
256
- .where('user_id', 'in', p.userIds)
257
- .where('otoy_grant_id', 'is not', null)
258
- .groupBy('user_id')
259
- .execute();
260
- // jobs already settled by a burn correction — their grant is spoken for (double-spend guard)
261
- const corrected = yield db
262
- .selectFrom(`${exports.JOB_TABLE} as j`)
263
- .innerJoin(`${exports.BURN_CORRECTION_TABLE} as bc`, 'bc.job_id', 'j.id')
264
- .select((eb) => ['j.user_id as user_id', eb.fn.sum('j.render_amt').as('amt')])
265
- .where('j.user_id', 'in', p.userIds)
266
- .groupBy('j.user_id')
267
- .execute();
268
- const spentBy = new Map(spent.map((r) => { var _a; return [r.user_id, BigInt((_a = r.net) !== null && _a !== void 0 ? _a : 0)]; }));
269
- const corrBy = new Map(corrected.map((r) => { var _a; return [r.user_id, BigInt((_a = r.amt) !== null && _a !== void 0 ? _a : 0)]; }));
270
- for (const g of grants) {
271
- const u = g.user_id;
272
- const cur = (_b = out[u]) !== null && _b !== void 0 ? _b : (out[u] = { userId: u, granted: BigInt(0), consumed: BigInt(0), avail: BigInt(0), grants: [] });
273
- // amount_credits is numeric EUR — truncate to whole base units after the x4 conversion
274
- const credits = (BigInt(Math.round(Number(g.amount_credits) * 1e8)) * exports.EUR_TO_CREDITS);
275
- cur.granted += credits;
276
- cur.grants.push({
277
- id: Number(g.id),
278
- amountCredits: credits,
279
- createdAt: g.otoy_created_at ? new Date(g.otoy_created_at) : null,
316
+ // Raw event stream, folded in code (foldGrantWindow — per-grant buckets, 60-day consumable window,
317
+ // reversals clamp capacity, only jobs create beyond-grants). SQL can't express the bucketed clamp.
318
+ const evRes = yield (0, kysely_1.sql) `
319
+ select user_id::text as u, coalesce(otoy_created_at, ${exports.GRANT_WINDOW_START}::timestamp) as t,
320
+ (amount_credits * 4 * 1e8)::numeric::text as amt, 'grant' as src, id as gid
321
+ from otoy_grant where user_id = any(${p.userIds}::uuid[])
322
+ union all
323
+ select user_id::text, ${exports.GRANT_WINDOW_START}::timestamp, sum(render_spent_delta)::text, 'grant', null
324
+ from user_grant_spend
325
+ where user_id = any(${p.userIds}::uuid[]) and render_spent_delta > 0 and otoy_grant_id is null
326
+ group by user_id
327
+ union all
328
+ select user_id::text, created_at, (-render_spent_delta)::text, 'grant', null
329
+ from user_grant_spend
330
+ where user_id = any(${p.userIds}::uuid[]) and render_spent_delta > 0 and otoy_grant_id is not null
331
+ union all
332
+ select user_id, completed_at, (-render_amt)::text, 'job', null
333
+ from job
334
+ where user_id = any(${p.userIds}::text[]) and completed_at >= ${exports.GRANT_WINDOW_START}::timestamp
335
+ `.execute(db); // db is always a Kysely trx at runtime
336
+ const byUser = new Map();
337
+ for (const r of evRes.rows) {
338
+ const arr = (_b = byUser.get(r.u)) !== null && _b !== void 0 ? _b : [];
339
+ arr.push({
340
+ t: new Date(r.t).getTime(),
341
+ amt: BigInt(Math.round(Number(r.amt))),
342
+ src: r.src,
343
+ grantId: (_c = r.gid) !== null && _c !== void 0 ? _c : undefined,
280
344
  });
345
+ byUser.set(r.u, arr);
281
346
  }
282
- for (const u of Object.keys(out)) {
283
- const attributed = (_c = spentBy.get(u)) !== null && _c !== void 0 ? _c : BigInt(0); // abs() — spend drawn or grant already credited
284
- const viaCorrection = (_d = corrBy.get(u)) !== null && _d !== void 0 ? _d : BigInt(0);
285
- const f = (0, exports.foldGrantAllotment)(out[u].grants, attributed, viaCorrection);
286
- out[u].granted = f.granted;
287
- out[u].consumed = f.consumed;
288
- out[u].avail = f.avail;
289
- if (viaCorrection > BigInt(0)) {
290
- log.info(`grant allotment ${u}: granted ${f.granted} - attributed ${attributed} - correction-settled ${viaCorrection} = ${f.avail}`);
347
+ for (const [u, events] of byUser) {
348
+ const f = (0, exports.foldGrantWindow)(events);
349
+ out[u] = {
350
+ userId: u,
351
+ granted: events.reduce((a, e) => (e.src === 'grant' && e.amt > BigInt(0) ? a + e.amt : a), BigInt(0)),
352
+ consumed: f.jobCharged,
353
+ avail: f.avail,
354
+ expired: f.expired,
355
+ fifoGrantId: f.fifoGrantId,
356
+ };
357
+ if (f.beyondGrants > BigInt(0)) {
358
+ log.info(`grant allotment ${u}: ${f.beyondGrants} beyond grants (purchase-paid at the time), ` +
359
+ `${f.expired} expired past the ${exports.GRANT_LOOKBACK_DAYS}d window — avail ${f.avail}`);
291
360
  }
292
361
  }
293
362
  return out;
@@ -372,7 +441,7 @@ const pgClient = (config) => {
372
441
  const sequenceNumbers = [];
373
442
  const dbEpochIds = [];
374
443
  for (const bt of bts) {
375
- let { solKey: toSolKey, seq, createdAt, amount, upgradeBracket, priorSupply, points, epochId, } = bt;
444
+ let { solKey: toSolKey, seq, createdAt, amount, upgradeBracket, priorSupply, points, epochId, sender, } = bt;
376
445
  let entityId;
377
446
  if (toSolKey && toSolKey != '') {
378
447
  entityId = yield getOrInsertEntity(db, { solKey: toSolKey });
@@ -393,6 +462,7 @@ const pgClient = (config) => {
393
462
  prior_supply: priorSupply,
394
463
  points: points,
395
464
  epoch_id: dbEpochId,
465
+ sender: sender,
396
466
  })
397
467
  .onConflict((oc) => oc.column('sequence').doUpdateSet({
398
468
  created_at: createdAt,
@@ -402,6 +472,7 @@ const pgClient = (config) => {
402
472
  points: points,
403
473
  epoch_id: dbEpochId,
404
474
  entity_id: entityId,
475
+ sender: sender,
405
476
  }))
406
477
  .returning('sequence');
407
478
  const c = q.compile();
@@ -1156,7 +1227,7 @@ const pgClient = (config) => {
1156
1227
  });
1157
1228
  },
1158
1229
  insertGrantSpend(db, p, log = logger_1.consoleLogger) {
1159
- var _a, _b, _c;
1230
+ var _a, _b, _c, _d;
1160
1231
  return __awaiter(this, void 0, void 0, function* () {
1161
1232
  const existing = yield getCurrentUserGrantSpend(db.trx, { userId: p.userId }, log);
1162
1233
  const diff = {
@@ -1183,16 +1254,13 @@ const pgClient = (config) => {
1183
1254
  return;
1184
1255
  }
1185
1256
  }
1186
- // FIFO-attribute this spend to the user's oldest OTOY grant that still has room. Stamping
1187
- // otoy_grant_id is what makes getGrantAllotments count it as consumed an unattributed row
1188
- // would leave the grant looking unspent and let it fund a second burn.
1257
+ // FIFO-attribute this spend to the user's oldest LIVE OTOY grant that still has room (the fold
1258
+ // already applies the lookback window). Stamping otoy_grant_id is what shows the draw in the
1259
+ // admin drill-down — an unattributed row would leave the grant looking unspent there.
1189
1260
  let otoyGrantId = p.otoyGrantId;
1190
1261
  if (otoyGrantId === undefined && p.renderSpentDelta < BigInt(0)) {
1191
1262
  const allots = yield getGrantAllotments(db.trx, { userIds: [p.userId] }, log);
1192
- const a = allots[p.userId];
1193
- if (a) {
1194
- otoyGrantId = (0, exports.foldGrantAllotment)(a.grants, a.consumed, BigInt(0)).fifoGrantId;
1195
- }
1263
+ otoyGrantId = (_c = allots[p.userId]) === null || _c === void 0 ? void 0 : _c.fifoGrantId;
1196
1264
  }
1197
1265
  const v = {
1198
1266
  user_id: p.userId,
@@ -1218,7 +1286,7 @@ const pgClient = (config) => {
1218
1286
  user_id: p.userId,
1219
1287
  render_spent: p.renderSpentDelta,
1220
1288
  description: p.description,
1221
- perpetual: (_c = p.perpetual) !== null && _c !== void 0 ? _c : false,
1289
+ perpetual: (_d = p.perpetual) !== null && _d !== void 0 ? _d : false,
1222
1290
  })
1223
1291
  .onConflict((oc) => oc.column('user_id').doUpdateSet(diff))
1224
1292
  .returning((eb) => [
@@ -1563,138 +1631,75 @@ const pgClient = (config) => {
1563
1631
  fetchBurnsPage(db, f, log = logger_1.consoleLogger) {
1564
1632
  var _a;
1565
1633
  return __awaiter(this, void 0, void 0, function* () {
1566
- const params = [];
1567
- const p = (v) => {
1568
- params.push(v);
1569
- return `$${params.length}`;
1570
- };
1571
- // Decode cursor — supports new {t,id,ea} format and legacy bare-id format.
1572
- let cursorEa;
1573
- let cursorType;
1574
- let cursorId;
1575
- if (f.cursor) {
1576
- const raw = atob(f.cursor);
1577
- try {
1578
- const c = JSON.parse(raw);
1579
- cursorEa = new Date(c.ea);
1580
- cursorType = c.t;
1581
- cursorId = c.id;
1582
- }
1583
- catch (_b) {
1584
- // Legacy cursor: bare burn.id — treat as buy-burn cursor.
1585
- cursorId = Number(raw);
1586
- cursorType = 'buy';
1587
- }
1634
+ const lastId = f.cursor ? atob(f.cursor) : undefined;
1635
+ let q = db.trx
1636
+ .selectFrom('burn')
1637
+ .selectAll('burn')
1638
+ .innerJoin('sol_tx', 'sol_tx.id', 'burn.sol_tx_id')
1639
+ .select(['sol_tx.executed_at', 'sol_tx.sig']);
1640
+ if (!f.inclManual) {
1641
+ q = q.where('burn.usdc_spent', '>', String(0));
1642
+ }
1643
+ if (f.executedAfter) {
1644
+ q = q.where('sol_tx.executed_at', '>=', f.executedAfter);
1645
+ }
1646
+ if (f.executedBefore) {
1647
+ q = q.where('sol_tx.executed_at', '<=', f.executedBefore);
1648
+ }
1649
+ if (f.sig) {
1650
+ // Exact match, not a prefix: sol_tx.sig is unique, so this is an index hit returning at most one row.
1651
+ q = q.where('sol_tx.sig', '=', f.sig);
1588
1652
  }
1589
- // Shared WHERE fragments pushed into each union leg for index use.
1590
- const dateFilters = [];
1591
- if (f.executedAfter)
1592
- dateFilters.push(`st.executed_at >= ${p(f.executedAfter)}`);
1593
- if (f.executedBefore)
1594
- dateFilters.push(`st.executed_at <= ${p(f.executedBefore)}`);
1595
- const sigFilter = f.sig ? `st.sig = ${p(f.sig)}` : '';
1596
- // Buy-burn leg
1597
- const buyWhere = [...dateFilters];
1598
- if (!f.inclManual)
1599
- buyWhere.push('b.usdc_spent > 0');
1600
- if (sigFilter)
1601
- buyWhere.push(sigFilter);
1602
- if (f.jobId !== undefined) {
1603
- buyWhere.push(`b.id IN (SELECT burn_id FROM job WHERE id = ${p(f.jobId)})`);
1604
- }
1605
- const buyWhereClause = buyWhere.length ? `WHERE ${buyWhere.join(' AND ')}` : '';
1606
- const buySql = `
1607
- SELECT 'buy'::text AS type, b.id::bigint AS sortid, b.id AS id,
1608
- st.executed_at, b.priced_at, b.burned::text AS burned,
1609
- b.usdc_spent::text AS usdc_spent, b.quote_amt::text AS quote_amt,
1610
- b.tags, st.sig, b.render_to_usdc, b.eur_to_usdc,
1611
- coalesce(b.from_escrow_usdc, 0)::text AS from_escrow_usdc,
1612
- b.down_correction_id, b.escrow_correction_id,
1613
- b.marked_burned_at
1614
- FROM burn b
1615
- INNER JOIN sol_tx st ON st.id = b.sol_tx_id
1616
- ${buyWhereClause}`;
1617
- // Grant-burn leg
1618
- const grantWhere = [...dateFilters];
1619
- if (sigFilter)
1620
- grantWhere.push(sigFilter);
1621
1653
  if (f.jobId !== undefined) {
1622
- grantWhere.push(`gb.id IN (SELECT grant_burn_id FROM job WHERE id = ${p(f.jobId)})`);
1623
- }
1624
- const grantWhereClause = grantWhere.length ? `WHERE ${grantWhere.join(' AND ')}` : '';
1625
- const grantSql = `
1626
- SELECT 'grant'::text AS type, gb.id::bigint AS sortid, gb.id AS id,
1627
- st.executed_at, null::timestamp AS priced_at, gb.burned::text AS burned,
1628
- '0' AS usdc_spent, '0' AS quote_amt,
1629
- gb.tags, st.sig, null::float8 AS render_to_usdc, null::float8 AS eur_to_usdc,
1630
- '0' AS from_escrow_usdc,
1631
- null::bigint AS down_correction_id, null::bigint AS escrow_correction_id,
1632
- gb.marked_burned_at
1633
- FROM grant_burn gb
1634
- INNER JOIN sol_tx st ON st.id = gb.sol_tx_id
1635
- ${grantWhereClause}`;
1636
- // Cursor filter on the outer query — row comparison for deterministic paging.
1637
- let cursorFilter = '';
1638
- if (cursorEa && cursorType && cursorId !== undefined) {
1639
- cursorFilter = `WHERE (u.executed_at, u.type, u.sortid) > (${p(cursorEa)}, ${p(cursorType)}, ${p(cursorId)})`;
1640
- }
1641
- else if (cursorId !== undefined) {
1642
- // Legacy cursor fallback: only buy burns, ordered by id.
1643
- cursorFilter = `WHERE u.type = 'buy' AND u.sortid > ${p(cursorId)}`;
1654
+ // "Which burn consumed this job?" job.burn_id is the FK, so resolve it as a subquery rather than
1655
+ // joining `job` into the page query, which would multiply rows by job count and break both the limit
1656
+ // and the cursor.
1657
+ q = q.where('burn.id', 'in', (eb) => eb
1658
+ .selectFrom('job')
1659
+ .select('job.burn_id')
1660
+ .where('job.id', '=', String(f.jobId)));
1661
+ }
1662
+ if (lastId) {
1663
+ q = q.where('burn.id', '>', lastId);
1644
1664
  }
1645
1665
  const limit = (_a = f.limit) !== null && _a !== void 0 ? _a : 1000;
1646
- const sql = `
1647
- SELECT u.* FROM (
1648
- ${buySql}
1649
- UNION ALL
1650
- ${grantSql}
1651
- ) u
1652
- ${cursorFilter}
1653
- ORDER BY u.executed_at ASC, u.type, u.sortid
1654
- LIMIT ${p(limit)}`;
1655
- const { rows } = yield (0, base_1.timedQuery)(pgPool, sql, params, 'fetchBurnsPage', log);
1666
+ q = q.limit(limit).orderBy('burn.id');
1667
+ const comp = q.compile();
1668
+ const { rows } = yield (0, base_1.timedQuery)(pgPool, comp.sql, comp.parameters, 'fetchBurnsPage', log);
1656
1669
  const burns = rows.map((r) => {
1657
- var _a, _b, _c, _d, _e, _f;
1670
+ var _a, _b;
1658
1671
  return ({
1659
1672
  jobs: [],
1660
1673
  id: Number(r.id),
1661
- type: r.type,
1662
- renderToUsdc: (_a = r.render_to_usdc) !== null && _a !== void 0 ? _a : undefined,
1674
+ renderToUsdc: r.render_to_usdc,
1663
1675
  solTx: r.sig,
1664
1676
  executedAt: r.executed_at,
1665
- pricedAt: (_b = r.priced_at) !== null && _b !== void 0 ? _b : undefined,
1666
- usdcSpent: BigInt((_c = r.usdc_spent) !== null && _c !== void 0 ? _c : 0),
1667
- quoteAmt: BigInt((_d = r.quote_amt) !== null && _d !== void 0 ? _d : 0),
1677
+ pricedAt: r.priced_at,
1678
+ usdcSpent: BigInt((_a = r.usdc_spent) !== null && _a !== void 0 ? _a : 0),
1679
+ quoteAmt: BigInt((_b = r.quote_amt) !== null && _b !== void 0 ? _b : 0),
1668
1680
  burned: BigInt(r.burned),
1669
- eurToUsdc: (_e = r.eur_to_usdc) !== null && _e !== void 0 ? _e : undefined,
1681
+ eurToUsdc: r.eur_to_usdc,
1670
1682
  markedBurnedAt: r.marked_burned_at,
1671
1683
  tags: r.tags ? r.tags.split(',') : [],
1672
- fromEscrowUsdc: BigInt((_f = r.from_escrow_usdc) !== null && _f !== void 0 ? _f : 0),
1673
- downCorrectionId: r.down_correction_id ? Number(r.down_correction_id) : undefined,
1674
- escrowCorrectionId: r.escrow_correction_id ? Number(r.escrow_correction_id) : undefined,
1675
1684
  });
1676
1685
  });
1677
1686
  return {
1678
1687
  burns,
1688
+ // Only hand back a cursor on a full page; a short page means we're done.
1679
1689
  cursor: burns.length === limit
1680
- ? btoa(JSON.stringify({
1681
- ea: rows[burns.length - 1].executed_at,
1682
- t: rows[burns.length - 1].type,
1683
- id: rows[burns.length - 1].sortid,
1684
- }))
1690
+ ? btoa(String(burns[burns.length - 1].id))
1685
1691
  : undefined,
1686
1692
  };
1687
1693
  });
1688
1694
  },
1689
- fetchBurnJobs(db, burnId, burnType = 'buy', log = logger_1.consoleLogger) {
1695
+ fetchBurnJobs(db, burnId, log = logger_1.consoleLogger) {
1690
1696
  return __awaiter(this, void 0, void 0, function* () {
1691
- const fk = burnType === 'grant' ? 'grant_burn_id' : 'burn_id';
1692
1697
  const rows = yield db.trx
1693
1698
  .selectFrom('job')
1694
1699
  .selectAll('job')
1695
- .where(fk, '=', String(burnId))
1700
+ .where('burn_id', '=', String(burnId))
1696
1701
  .execute();
1697
- log.debug(`fetchBurnJobs ${burnType} ${burnId} -> ${rows.length} jobs`);
1702
+ log.debug(`fetchBurnJobs burn ${burnId} -> ${rows.length} jobs`);
1698
1703
  return rows.map((r) => ({
1699
1704
  id: Number(r.id),
1700
1705
  completedAt: r.completed_at,
@@ -2017,7 +2022,23 @@ const pgClient = (config) => {
2017
2022
  });
2018
2023
  },
2019
2024
  insertDispersedOutbox(p, log = logger_1.consoleLogger) {
2025
+ var _a;
2020
2026
  return __awaiter(this, void 0, void 0, function* () {
2027
+ // Check if dispersed_obligation table exists BEFORE the transaction so a missing table never
2028
+ // poisons the txn (PG aborts the entire transaction on any error, even a caught one).
2029
+ let hasObligationTable = false;
2030
+ if ((_a = p.obligations) === null || _a === void 0 ? void 0 : _a.length) {
2031
+ try {
2032
+ const { rows } = yield (0, base_1.timedQuery)(pgPool, `SELECT 1 FROM information_schema.tables WHERE table_name = 'dispersed_obligation' LIMIT 1`, [], 'checkObligationTable', log);
2033
+ hasObligationTable = rows.length > 0;
2034
+ }
2035
+ catch (_b) {
2036
+ hasObligationTable = false;
2037
+ }
2038
+ if (!hasObligationTable) {
2039
+ log.warn(`dispersed obligations: table not found (migration 44 pending); skipping for tx ${p.txSignature}`);
2040
+ }
2041
+ }
2021
2042
  // The outbox row is the durable, crash-safe anchor persisted BEFORE broadcast. When it carries a
2022
2043
  // burn-correction draw, we increment consumed_render in the SAME transaction as the insert, bounded
2023
2044
  // so it can never exceed amount_render. Row + counter are therefore atomic and inseparable: no
@@ -2025,8 +2046,8 @@ const pgClient = (config) => {
2025
2046
  // (decremented again by voidDispersedOutbox). If the bounded increment can't fit (a concurrent draw
2026
2047
  // took the room), the whole txn rolls back and we return {inserted:false} — the caller must NOT
2027
2048
  // broadcast (no row, no counter change, no burn).
2028
- return yield db.transaction().execute((trx) => __awaiter(this, void 0, void 0, function* () {
2029
- var _a, _b, _c, _d, _e, _f, _g;
2049
+ const result = yield db.transaction().execute((trx) => __awaiter(this, void 0, void 0, function* () {
2050
+ var _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
2030
2051
  const res = yield trx
2031
2052
  .insertInto(exports.DISPERSED_SETTLEMENT_TABLE)
2032
2053
  .values({
@@ -2036,15 +2057,17 @@ const pgClient = (config) => {
2036
2057
  settled_amount: p.settledAmount,
2037
2058
  burned_amount: p.burnedAmount,
2038
2059
  executed_at: p.executedAt,
2039
- eur_render: (_a = p.eurRender) !== null && _a !== void 0 ? _a : null,
2040
- last_valid_block_height: (_b = p.lastValidBlockHeight) !== null && _b !== void 0 ? _b : null,
2041
- usdc_spent: (_c = p.usdcSpent) !== null && _c !== void 0 ? _c : null,
2042
- correction_id: (_d = p.correctionId) !== null && _d !== void 0 ? _d : null,
2043
- correction_render: (_e = p.correctionRender) !== null && _e !== void 0 ? _e : null,
2060
+ eur_render: (_c = p.eurRender) !== null && _c !== void 0 ? _c : null,
2061
+ last_valid_block_height: (_d = p.lastValidBlockHeight) !== null && _d !== void 0 ? _d : null,
2062
+ usdc_spent: (_e = p.usdcSpent) !== null && _e !== void 0 ? _e : null,
2063
+ correction_id: (_f = p.correctionId) !== null && _f !== void 0 ? _f : null,
2064
+ correction_render: (_g = p.correctionRender) !== null && _g !== void 0 ? _g : null,
2065
+ usage_from: (_h = p.usageFrom) !== null && _h !== void 0 ? _h : null,
2066
+ usage_to: (_j = p.usageTo) !== null && _j !== void 0 ? _j : null,
2044
2067
  })
2045
2068
  .onConflict((oc) => oc.column('tx_signature').doNothing())
2046
2069
  .executeTakeFirst();
2047
- const inserted = ((_f = res.numInsertedOrUpdatedRows) !== null && _f !== void 0 ? _f : BigInt(0)) > BigInt(0);
2070
+ const inserted = ((_k = res.numInsertedOrUpdatedRows) !== null && _k !== void 0 ? _k : BigInt(0)) > BigInt(0);
2048
2071
  // Only reserve when the row was actually inserted (a tx_signature conflict is a no-op → no double
2049
2072
  // count) and it drew a correction.
2050
2073
  if (inserted &&
@@ -2059,19 +2082,35 @@ const pgClient = (config) => {
2059
2082
  .where('id', '=', String(p.correctionId))
2060
2083
  .where((eb) => eb((0, kysely_1.sql) `consumed_render + ${p.correctionRender}::numeric`, '<=', eb.ref('amount_render')))
2061
2084
  .executeTakeFirst();
2062
- if (((_g = upd.numUpdatedRows) !== null && _g !== void 0 ? _g : BigInt(0)) === BigInt(0)) {
2085
+ if (((_l = upd.numUpdatedRows) !== null && _l !== void 0 ? _l : BigInt(0)) === BigInt(0)) {
2063
2086
  // Would exceed amount_render (concurrent draw won the room) — roll back the whole txn.
2064
2087
  log.warn(`dispersed correction ${p.correctionId}: draw ${p.correctionRender} would over-consume; rejecting outbox insert for tx ${p.txSignature}`);
2065
2088
  throw new DispersedCorrectionRejected();
2066
2089
  }
2067
2090
  }
2068
- log.debug(`dispersed outbox tx ${p.txSignature}: ${res.numInsertedOrUpdatedRows} row(s)`);
2091
+ if (inserted && hasObligationTable && ((_m = p.obligations) === null || _m === void 0 ? void 0 : _m.length)) {
2092
+ for (const o of p.obligations) {
2093
+ yield trx
2094
+ .insertInto('dispersed_obligation')
2095
+ .values({
2096
+ settlement_tx_sig: p.txSignature,
2097
+ uuid: o.uuid,
2098
+ amount_eur: o.amountEur,
2099
+ opened_at: o.openedAt,
2100
+ authorization_uuid: (_o = o.authorizationUuid) !== null && _o !== void 0 ? _o : null,
2101
+ })
2102
+ .onConflict((oc) => oc.column('uuid').doNothing())
2103
+ .execute();
2104
+ }
2105
+ }
2106
+ log.debug(`dispersed outbox tx ${p.txSignature}: ${res.numInsertedOrUpdatedRows} row(s), ${hasObligationTable ? ((_q = (_p = p.obligations) === null || _p === void 0 ? void 0 : _p.length) !== null && _q !== void 0 ? _q : 0) : 0} obligations`);
2069
2107
  return { inserted };
2070
2108
  })).catch((e) => {
2071
2109
  if (e instanceof DispersedCorrectionRejected)
2072
2110
  return { inserted: false };
2073
2111
  throw e;
2074
2112
  });
2113
+ return result;
2075
2114
  });
2076
2115
  },
2077
2116
  getOutstandingDispersedCorrection(log = logger_1.consoleLogger) {