@render-foundation/utils 0.0.254 → 0.0.255

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.
@@ -33,34 +33,114 @@ BigInt.prototype.toJSON = function () {
33
33
  // confirmed_amount = EUR, despite the column name); 1 render credit = EUR 0.25, so credits = EUR x 4.
34
34
  // job.render_amt / user_grant_spend.render_spent_delta are credits x 1e8.
35
35
  export const EUR_TO_CREDITS = BigInt(4);
36
+ // Grant accounting window: otoy_grant mirrors OTOY RFG issuances from this date. Consumption against the
37
+ // window counts EVERY job the user ran in it — buy-and-burn jobs consumed OTOY credits exactly like
38
+ // emissions-funded ones (counting only emissions-attributed spend over-issued ~2.58M credits, 2026-08-04).
39
+ export const GRANT_WINDOW_START = '2025-05-01';
36
40
  export const CREDIT_DECIMALS = BigInt(100000000); // 1e8
37
- /**
38
- * Pure core of the grant allotment: fold a user's OTOY grants against what's already been spent.
39
- *
40
- * consumed = attributedSpend (spend already stamped with an otoy_grant_id)
41
- * + correctionSettled (jobs whose grant is already being settled by a burn_correction)
42
- *
43
- * The correctionSettled term is the DOUBLE-SPEND GUARD: an escrow_credit correction means we're
44
- * recovering that job's dollars from escrow, so its grant is already committed. Counting it here stops
45
- * the same grant from also funding a future emissions burn.
46
- *
47
- * fifoGrantId = the oldest grant still holding room, i.e. the one the next spend draws from.
48
- */
49
- export const foldGrantAllotment = (grants, attributedSpend, correctionSettled) => {
50
- const granted = grants.reduce((s, g) => s + g.amountCredits, BigInt(0));
51
- const consumed = (attributedSpend > BigInt(0) ? attributedSpend : BigInt(0)) +
52
- (correctionSettled > BigInt(0) ? correctionSettled : BigInt(0));
53
- const avail = granted > consumed ? granted - consumed : BigInt(0);
54
- let drawn = consumed;
41
+ // A grant is consumable for this long after issuance; whatever is left after that EXPIRES (2026-08-05
42
+ // model change). A job can only draw grants issued within the lookback window before it ran.
43
+ export const GRANT_LOOKBACK_DAYS = 60;
44
+ export const GRANT_LOOKBACK_MS = GRANT_LOOKBACK_DAYS * 24 * 3600 * 1000;
45
+ export const foldGrantWindow = (events, opts = {}) => {
46
+ const lookback = opts.lookbackMs ?? GRANT_LOOKBACK_MS;
47
+ const now = opts.now ?? Date.now();
48
+ const sorted = [...events].sort((a, b) => a.t - b.t || (a.amt > b.amt ? -1 : 1)); // credits first on ties
49
+ const buckets = [];
50
+ const jobs = [];
51
+ const remaining = (b) => b.amount - b.reversed - b.drawnEmissions - b.drawnMisrouted;
52
+ let beyond = BigInt(0);
53
+ for (const e of sorted) {
54
+ if (e.src === 'grant') {
55
+ if (e.amt > BigInt(0)) {
56
+ buckets.push({
57
+ grantId: e.grantId,
58
+ t: e.t,
59
+ amount: e.amt,
60
+ reversed: BigInt(0),
61
+ drawnEmissions: BigInt(0),
62
+ drawnMisrouted: BigInt(0),
63
+ expired: BigInt(0),
64
+ left: BigInt(0),
65
+ });
66
+ }
67
+ else {
68
+ let claw = -e.amt; // clawback hits remaining capacity oldest-first; excess is dropped
69
+ for (const b of buckets) {
70
+ if (claw <= BigInt(0))
71
+ break;
72
+ const take = remaining(b) < claw ? remaining(b) : claw;
73
+ if (take > BigInt(0)) {
74
+ b.reversed += take;
75
+ claw -= take;
76
+ }
77
+ }
78
+ }
79
+ }
80
+ else {
81
+ let need = -e.amt;
82
+ const total = need;
83
+ let emissionsLeft = e.emissions ?? need; // no split provided -> count it all as emissions
84
+ let jobEm = BigInt(0);
85
+ let jobMis = BigInt(0);
86
+ for (const b of buckets) {
87
+ if (need <= BigInt(0))
88
+ break;
89
+ if (b.t + lookback < e.t)
90
+ continue; // grant expired before this job ran
91
+ const take = remaining(b) < need ? remaining(b) : need;
92
+ if (take <= BigInt(0))
93
+ continue;
94
+ const em = emissionsLeft < take ? emissionsLeft : take;
95
+ b.drawnEmissions += em;
96
+ b.drawnMisrouted += take - em;
97
+ jobEm += em;
98
+ jobMis += take - em;
99
+ emissionsLeft -= em;
100
+ need -= take;
101
+ }
102
+ beyond += need;
103
+ jobs.push({
104
+ id: e.id,
105
+ t: e.t,
106
+ need: total,
107
+ drawnEmissions: jobEm,
108
+ drawnMisrouted: jobMis,
109
+ beyond: need,
110
+ });
111
+ }
112
+ }
113
+ let avail = BigInt(0);
114
+ let expired = BigInt(0);
115
+ let usedEmissions = BigInt(0);
116
+ let usedMisrouted = BigInt(0);
55
117
  let fifoGrantId;
56
- for (const g of grants) {
57
- if (drawn < g.amountCredits) {
58
- fifoGrantId = g.id;
59
- break;
118
+ for (const b of buckets) {
119
+ usedEmissions += b.drawnEmissions;
120
+ usedMisrouted += b.drawnMisrouted;
121
+ const rem = remaining(b);
122
+ if (b.t + lookback < now) {
123
+ b.expired = rem;
124
+ }
125
+ else {
126
+ b.left = rem;
127
+ avail += rem;
128
+ if (fifoGrantId === undefined && rem > BigInt(0) && b.grantId !== undefined)
129
+ fifoGrantId = b.grantId;
60
130
  }
61
- drawn -= g.amountCredits;
131
+ expired += b.expired;
62
132
  }
63
- return { granted, consumed, avail, fifoGrantId };
133
+ return {
134
+ avail,
135
+ beyondGrants: beyond,
136
+ jobCharged: usedEmissions + usedMisrouted,
137
+ usedEmissions,
138
+ usedMisrouted,
139
+ expired,
140
+ buckets,
141
+ jobs,
142
+ fifoGrantId,
143
+ };
64
144
  };
65
145
  import { begin } from './base';
66
146
  import moment from 'moment';
@@ -190,63 +270,50 @@ export const pgClient = (config) => {
190
270
  const out = {};
191
271
  if (!p.userIds.length)
192
272
  return out;
193
- const grants = await db
194
- .selectFrom(OTOY_GRANT_TABLE)
195
- .select(['id', 'user_id', 'amount_credits', 'otoy_created_at'])
196
- .where('user_id', 'in', p.userIds)
197
- .orderBy('otoy_created_at', 'asc')
198
- .orderBy('id', 'asc')
199
- .execute();
200
- if (!grants.length)
201
- return out;
202
- // Anything already booked against a pool grant, in EITHER direction:
203
- // - negative delta = spend drawn from the grant (this mechanism)
204
- // - positive delta = the grant was CREDITED into current_user_grant_spend (the one-off canary
205
- // route). That balance is already handed to the caller separately, so leaving it in the pool too
206
- // would hand out the same grant twice.
207
- // Hence abs(): both directions retire pool capacity.
208
- const spent = await db
209
- .selectFrom(USER_GRANT_SPEND_TABLE)
210
- .select((eb) => [
211
- 'user_id',
212
- eb.fn.sum(sql `abs(render_spent_delta)`).as('net'),
213
- ])
214
- .where('user_id', 'in', p.userIds)
215
- .where('otoy_grant_id', 'is not', null)
216
- .groupBy('user_id')
217
- .execute();
218
- // jobs already settled by a burn correction — their grant is spoken for (double-spend guard)
219
- const corrected = await db
220
- .selectFrom(`${JOB_TABLE} as j`)
221
- .innerJoin(`${BURN_CORRECTION_TABLE} as bc`, 'bc.job_id', 'j.id')
222
- .select((eb) => ['j.user_id as user_id', eb.fn.sum('j.render_amt').as('amt')])
223
- .where('j.user_id', 'in', p.userIds)
224
- .groupBy('j.user_id')
225
- .execute();
226
- const spentBy = new Map(spent.map((r) => [r.user_id, BigInt(r.net ?? 0)]));
227
- const corrBy = new Map(corrected.map((r) => [r.user_id, BigInt(r.amt ?? 0)]));
228
- for (const g of grants) {
229
- const u = g.user_id;
230
- const cur = out[u] ??
231
- (out[u] = { userId: u, granted: BigInt(0), consumed: BigInt(0), avail: BigInt(0), grants: [] });
232
- // amount_credits is numeric EUR — truncate to whole base units after the x4 conversion
233
- const credits = (BigInt(Math.round(Number(g.amount_credits) * 1e8)) * EUR_TO_CREDITS);
234
- cur.granted += credits;
235
- cur.grants.push({
236
- id: Number(g.id),
237
- amountCredits: credits,
238
- createdAt: g.otoy_created_at ? new Date(g.otoy_created_at) : null,
273
+ // Raw event stream, folded in code (foldGrantWindow — per-grant buckets, 60-day consumable window,
274
+ // reversals clamp capacity, only jobs create beyond-grants). SQL can't express the bucketed clamp.
275
+ const evRes = await sql `
276
+ select user_id::text as u, coalesce(otoy_created_at, ${GRANT_WINDOW_START}::timestamp) as t,
277
+ (amount_credits * 4 * 1e8)::numeric::text as amt, 'grant' as src, id as gid
278
+ from otoy_grant where user_id = any(${p.userIds}::uuid[])
279
+ union all
280
+ select user_id::text, ${GRANT_WINDOW_START}::timestamp, sum(render_spent_delta)::text, 'grant', null
281
+ from user_grant_spend
282
+ where user_id = any(${p.userIds}::uuid[]) and render_spent_delta > 0 and otoy_grant_id is null
283
+ group by user_id
284
+ union all
285
+ select user_id::text, created_at, (-render_spent_delta)::text, 'grant', null
286
+ from user_grant_spend
287
+ where user_id = any(${p.userIds}::uuid[]) and render_spent_delta > 0 and otoy_grant_id is not null
288
+ union all
289
+ select user_id, completed_at, (-render_amt)::text, 'job', null
290
+ from job
291
+ where user_id = any(${p.userIds}::text[]) and completed_at >= ${GRANT_WINDOW_START}::timestamp
292
+ `.execute(db); // db is always a Kysely trx at runtime
293
+ const byUser = new Map();
294
+ for (const r of evRes.rows) {
295
+ const arr = byUser.get(r.u) ?? [];
296
+ arr.push({
297
+ t: new Date(r.t).getTime(),
298
+ amt: BigInt(Math.round(Number(r.amt))),
299
+ src: r.src,
300
+ grantId: r.gid ?? undefined,
239
301
  });
302
+ byUser.set(r.u, arr);
240
303
  }
241
- for (const u of Object.keys(out)) {
242
- const attributed = spentBy.get(u) ?? BigInt(0); // abs() — spend drawn or grant already credited
243
- const viaCorrection = corrBy.get(u) ?? BigInt(0);
244
- const f = foldGrantAllotment(out[u].grants, attributed, viaCorrection);
245
- out[u].granted = f.granted;
246
- out[u].consumed = f.consumed;
247
- out[u].avail = f.avail;
248
- if (viaCorrection > BigInt(0)) {
249
- log.info(`grant allotment ${u}: granted ${f.granted} - attributed ${attributed} - correction-settled ${viaCorrection} = ${f.avail}`);
304
+ for (const [u, events] of byUser) {
305
+ const f = foldGrantWindow(events);
306
+ out[u] = {
307
+ userId: u,
308
+ granted: events.reduce((a, e) => (e.src === 'grant' && e.amt > BigInt(0) ? a + e.amt : a), BigInt(0)),
309
+ consumed: f.jobCharged,
310
+ avail: f.avail,
311
+ expired: f.expired,
312
+ fifoGrantId: f.fifoGrantId,
313
+ };
314
+ if (f.beyondGrants > BigInt(0)) {
315
+ log.info(`grant allotment ${u}: ${f.beyondGrants} beyond grants (purchase-paid at the time), ` +
316
+ `${f.expired} expired past the ${GRANT_LOOKBACK_DAYS}d window — avail ${f.avail}`);
250
317
  }
251
318
  }
252
319
  return out;
@@ -328,7 +395,7 @@ export const pgClient = (config) => {
328
395
  const sequenceNumbers = [];
329
396
  const dbEpochIds = [];
330
397
  for (const bt of bts) {
331
- let { solKey: toSolKey, seq, createdAt, amount, upgradeBracket, priorSupply, points, epochId, } = bt;
398
+ let { solKey: toSolKey, seq, createdAt, amount, upgradeBracket, priorSupply, points, epochId, sender, } = bt;
332
399
  let entityId;
333
400
  if (toSolKey && toSolKey != '') {
334
401
  entityId = await getOrInsertEntity(db, { solKey: toSolKey });
@@ -349,6 +416,7 @@ export const pgClient = (config) => {
349
416
  prior_supply: priorSupply,
350
417
  points: points,
351
418
  epoch_id: dbEpochId,
419
+ sender: sender,
352
420
  })
353
421
  .onConflict((oc) => oc.column('sequence').doUpdateSet({
354
422
  created_at: createdAt,
@@ -358,6 +426,7 @@ export const pgClient = (config) => {
358
426
  points: points,
359
427
  epoch_id: dbEpochId,
360
428
  entity_id: entityId,
429
+ sender: sender,
361
430
  }))
362
431
  .returning('sequence');
363
432
  const c = q.compile();
@@ -1100,16 +1169,13 @@ export const pgClient = (config) => {
1100
1169
  return;
1101
1170
  }
1102
1171
  }
1103
- // FIFO-attribute this spend to the user's oldest OTOY grant that still has room. Stamping
1104
- // otoy_grant_id is what makes getGrantAllotments count it as consumed an unattributed row
1105
- // would leave the grant looking unspent and let it fund a second burn.
1172
+ // FIFO-attribute this spend to the user's oldest LIVE OTOY grant that still has room (the fold
1173
+ // already applies the lookback window). Stamping otoy_grant_id is what shows the draw in the
1174
+ // admin drill-down — an unattributed row would leave the grant looking unspent there.
1106
1175
  let otoyGrantId = p.otoyGrantId;
1107
1176
  if (otoyGrantId === undefined && p.renderSpentDelta < BigInt(0)) {
1108
1177
  const allots = await getGrantAllotments(db.trx, { userIds: [p.userId] }, log);
1109
- const a = allots[p.userId];
1110
- if (a) {
1111
- otoyGrantId = foldGrantAllotment(a.grants, a.consumed, BigInt(0)).fifoGrantId;
1112
- }
1178
+ otoyGrantId = allots[p.userId]?.fifoGrantId;
1113
1179
  }
1114
1180
  const v = {
1115
1181
  user_id: p.userId,
@@ -1440,6 +1506,7 @@ export const pgClient = (config) => {
1440
1506
  eurToUsdc: r.eur_to_usdc,
1441
1507
  markedBurnedAt: r.marked_burned_at,
1442
1508
  tags: r.tags ? r.tags.split(',') : [],
1509
+ fromEscrowUsdc: BigInt(r.from_escrow_usdc ?? 0),
1443
1510
  });
1444
1511
  burnIdToIdx[r.id] = burns.length - 1;
1445
1512
  }
@@ -1609,8 +1676,8 @@ export const pgClient = (config) => {
1609
1676
  .innerJoin('sol_tx', 'sol_tx.id', 'burn.sol_tx_id')
1610
1677
  .select(({ fn }) => [
1611
1678
  dayExpr.as('day'),
1612
- fn.sum('burn.burned').as('render_burned'),
1613
- fn.sum('burn.usdc_spent').as('usdc_spent'),
1679
+ sql `SUM(CASE WHEN burn.burned > 0 THEN burn.burned WHEN COALESCE(burn.from_escrow_usdc, 0) > 0 AND burn.render_to_usdc > 0 THEN (burn.from_escrow_usdc::numeric / burn.render_to_usdc * 100)::bigint ELSE 0 END)`.as('render_burned'),
1680
+ sql `SUM(CASE WHEN burn.usdc_spent > 0 THEN burn.usdc_spent ELSE COALESCE(burn.from_escrow_usdc, 0) END)`.as('usdc_spent'),
1614
1681
  fn.count('burn.id').as('burn_count'),
1615
1682
  ])
1616
1683
  .groupBy(sql `date_trunc('day', sol_tx.executed_at)`)
@@ -1912,6 +1979,8 @@ export const pgClient = (config) => {
1912
1979
  usdc_spent: p.usdcSpent ?? null,
1913
1980
  correction_id: p.correctionId ?? null,
1914
1981
  correction_render: p.correctionRender ?? null,
1982
+ usage_from: p.usageFrom ?? null,
1983
+ usage_to: p.usageTo ?? null,
1915
1984
  })
1916
1985
  .onConflict((oc) => oc.column('tx_signature').doNothing())
1917
1986
  .executeTakeFirst();