@render-foundation/utils 0.0.248 → 0.0.250

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.
@@ -38,56 +38,94 @@ export const EUR_TO_CREDITS = BigInt(4);
38
38
  // emissions-funded ones (counting only emissions-attributed spend over-issued ~2.58M credits, 2026-08-04).
39
39
  export const GRANT_WINDOW_START = '2025-05-01';
40
40
  export const CREDIT_DECIMALS = BigInt(100000000); // 1e8
41
- /**
42
- * Pure core of the grant allotment: fold a user's OTOY grants against what's already been spent.
43
- *
44
- * consumed = attributedSpend (spend already stamped with an otoy_grant_id)
45
- * + correctionSettled (jobs whose grant is already being settled by a burn_correction)
46
- *
47
- * The correctionSettled term is the DOUBLE-SPEND GUARD: an escrow_credit correction means we're
48
- * recovering that job's dollars from escrow, so its grant is already committed. Counting it here stops
49
- * the same grant from also funding a future emissions burn.
50
- *
51
- * fifoGrantId = the oldest grant still holding room, i.e. the one the next spend draws from.
52
- */
53
- /**
54
- * Time-aware reservoir fold. Events = grant issuances (+, at issue time) and job spend (−, at job time),
55
- * processed chronologically: a job may only draw entitlement that existed WHEN IT RAN. Spend beyond the
56
- * balance at that moment is purchase-paid forever — a later grant must not retroactively absorb it
57
- * (the aggregate `granted − allSpend` formula did exactly that, under-issuing 256 users / 44,243 credits;
58
- * it also let the UI attribute jobs to grants issued days after they ran).
59
- *
60
- * Identity used everywhere (SQL + client): with net = Σamt and overshoot = max prefix deficit,
61
- * avail = max(net + overshoot, 0); beyondGrants = overshoot.
62
- */
63
- export const foldTimeAware = (events) => {
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();
64
48
  const sorted = [...events].sort((a, b) => a.t - b.t || (a.amt > b.amt ? -1 : 1)); // credits first on ties
65
- let run = BigInt(0);
66
- let minRun = BigInt(0);
49
+ const buckets = [];
50
+ const remaining = (b) => b.amount - b.reversed - b.drawnEmissions - b.drawnMisrouted;
51
+ let beyond = BigInt(0);
67
52
  for (const e of sorted) {
68
- run += e.amt;
69
- if (run < minRun)
70
- minRun = run;
53
+ if (e.src === 'grant') {
54
+ if (e.amt > BigInt(0)) {
55
+ buckets.push({
56
+ grantId: e.grantId,
57
+ t: e.t,
58
+ amount: e.amt,
59
+ reversed: BigInt(0),
60
+ drawnEmissions: BigInt(0),
61
+ drawnMisrouted: BigInt(0),
62
+ expired: BigInt(0),
63
+ left: BigInt(0),
64
+ });
65
+ }
66
+ else {
67
+ let claw = -e.amt; // clawback hits remaining capacity oldest-first; excess is dropped
68
+ for (const b of buckets) {
69
+ if (claw <= BigInt(0))
70
+ break;
71
+ const take = remaining(b) < claw ? remaining(b) : claw;
72
+ if (take > BigInt(0)) {
73
+ b.reversed += take;
74
+ claw -= take;
75
+ }
76
+ }
77
+ }
78
+ }
79
+ else {
80
+ let need = -e.amt;
81
+ let emissionsLeft = e.emissions ?? need; // no split provided -> count it all as emissions
82
+ for (const b of buckets) {
83
+ if (need <= BigInt(0))
84
+ break;
85
+ if (b.t + lookback < e.t)
86
+ continue; // grant expired before this job ran
87
+ const take = remaining(b) < need ? remaining(b) : need;
88
+ if (take <= BigInt(0))
89
+ continue;
90
+ const em = emissionsLeft < take ? emissionsLeft : take;
91
+ b.drawnEmissions += em;
92
+ b.drawnMisrouted += take - em;
93
+ emissionsLeft -= em;
94
+ need -= take;
95
+ }
96
+ beyond += need;
97
+ }
71
98
  }
72
- const overshoot = minRun < BigInt(0) ? -minRun : BigInt(0);
73
- const avail = run + overshoot; // ≥ 0 by construction
74
- return { avail, beyondGrants: overshoot };
75
- };
76
- export const foldGrantAllotment = (grants, attributedSpend, correctionSettled) => {
77
- const granted = grants.reduce((s, g) => s + g.amountCredits, BigInt(0));
78
- const consumed = (attributedSpend > BigInt(0) ? attributedSpend : BigInt(0)) +
79
- (correctionSettled > BigInt(0) ? correctionSettled : BigInt(0));
80
- const avail = granted > consumed ? granted - consumed : BigInt(0);
81
- let drawn = consumed;
99
+ let avail = BigInt(0);
100
+ let expired = BigInt(0);
101
+ let usedEmissions = BigInt(0);
102
+ let usedMisrouted = BigInt(0);
82
103
  let fifoGrantId;
83
- for (const g of grants) {
84
- if (drawn < g.amountCredits) {
85
- fifoGrantId = g.id;
86
- break;
104
+ for (const b of buckets) {
105
+ usedEmissions += b.drawnEmissions;
106
+ usedMisrouted += b.drawnMisrouted;
107
+ const rem = remaining(b);
108
+ if (b.t + lookback < now) {
109
+ b.expired = rem;
110
+ }
111
+ else {
112
+ b.left = rem;
113
+ avail += rem;
114
+ if (fifoGrantId === undefined && rem > BigInt(0) && b.grantId !== undefined)
115
+ fifoGrantId = b.grantId;
87
116
  }
88
- drawn -= g.amountCredits;
117
+ expired += b.expired;
89
118
  }
90
- return { granted, consumed, avail, fifoGrantId };
119
+ return {
120
+ avail,
121
+ beyondGrants: beyond,
122
+ jobCharged: usedEmissions + usedMisrouted,
123
+ usedEmissions,
124
+ usedMisrouted,
125
+ expired,
126
+ buckets,
127
+ fifoGrantId,
128
+ };
91
129
  };
92
130
  import { begin } from './base';
93
131
  import moment from 'moment';
@@ -217,88 +255,50 @@ export const pgClient = (config) => {
217
255
  const out = {};
218
256
  if (!p.userIds.length)
219
257
  return out;
220
- const grants = await db
221
- .selectFrom(OTOY_GRANT_TABLE)
222
- .select(['id', 'user_id', 'amount_credits', 'otoy_created_at'])
223
- .where('user_id', 'in', p.userIds)
224
- .orderBy('otoy_created_at', 'asc')
225
- .orderBy('id', 'asc')
226
- .execute();
227
- if (!grants.length)
228
- return out;
229
- // EVERY grant-ledger row retires pool capacity, in either direction:
230
- // - negative delta = an emissions burn. ALL of them count, not just ones stamped with an
231
- // otoy_grant_id. Historical grants are now backfilled to cover the Sep/Oct-2025 burns, so those
232
- // burns must net against them; counting only stamped rows would re-issue ~1.06M credits of
233
- // already-spent grant.
234
- // - positive delta = the grant was CREDITED into current_user_grant_spend (the one-off canary
235
- // route). That balance reaches the split separately, so leaving it in the pool double-issues it.
236
- // Hence abs() over the whole ledger.
237
- // NOT abs() over the whole ledger — that would charge the Sep/Oct-2025 CSV *credits* as if they
238
- // were spend, double-penalising those users (464e8e33: 376,749 burned + 79,304 credited = 456,053).
239
- // A positive only retires capacity when it is tied to a pool grant (the canary route, where the
240
- // grant was moved into current_user_grant_spend and reaches the split from there).
241
- const spent = await db
242
- .selectFrom(USER_GRANT_SPEND_TABLE)
243
- .select((eb) => [
244
- 'user_id',
245
- eb.fn
246
- .sum(sql `case when render_spent_delta < 0 then -render_spent_delta
247
- when otoy_grant_id is not null then render_spent_delta
248
- else 0 end`)
249
- .as('net'),
250
- ])
251
- .where('user_id', 'in', p.userIds)
252
- .groupBy('user_id')
253
- .execute();
254
- // Time-aware fold (see foldTimeAware): one pass over the user's event stream — grant issuances
255
- // (+, at issue time; the legacy CSV credit at window start; pool-stamped credits as capacity moved
256
- // out at their created_at) and job spend (−, at completion). A job can only draw entitlement that
257
- // existed when it ran; overshoot is purchase-paid forever.
258
- const fold = await sql `
259
- with ev as (
260
- select user_id::text as u, coalesce(otoy_created_at, ${GRANT_WINDOW_START}::timestamp) as t,
261
- (amount_credits * 4 * 1e8)::numeric as amt
262
- from otoy_grant where user_id = any(${p.userIds}::uuid[])
263
- union all
264
- select user_id::text, ${GRANT_WINDOW_START}::timestamp, sum(render_spent_delta)::numeric
265
- from user_grant_spend
266
- where user_id = any(${p.userIds}::uuid[]) and render_spent_delta > 0 and otoy_grant_id is null
267
- group by user_id
268
- union all
269
- select user_id::text, created_at, -render_spent_delta::numeric
270
- from user_grant_spend
271
- where user_id = any(${p.userIds}::uuid[]) and render_spent_delta > 0 and otoy_grant_id is not null
272
- union all
273
- select user_id, completed_at, -render_amt::numeric
274
- from job
275
- where user_id = any(${p.userIds}::text[]) and completed_at >= ${GRANT_WINDOW_START}::timestamp
276
- ),
277
- r as (
278
- select u, amt,
279
- sum(amt) over (partition by u order by t asc, amt desc rows unbounded preceding) as run
280
- from ev
281
- )
282
- select u as user_id,
283
- sum(case when amt > 0 then amt else 0 end)::text as granted_total,
284
- sum(amt)::text as net,
285
- greatest(-min(run), 0)::text as overshoot
286
- from r group by u
287
- `.execute(db); // db is always a Kysely trx at runtime; QueryCreator's type just lacks getExecutor
288
- const toB = (v) => BigInt(Math.round(Number(v ?? 0)));
289
- for (const r of fold.rows) {
290
- const u = r.user_id;
291
- const cur = out[u] ??
292
- (out[u] = { userId: u, granted: BigInt(0), consumed: BigInt(0), avail: BigInt(0), grants: [] });
293
- const granted = toB(r.granted_total);
294
- const net = toB(r.net);
295
- const overshoot = toB(r.overshoot);
296
- const avail = net + overshoot > BigInt(0) ? net + overshoot : BigInt(0);
297
- cur.granted = granted;
298
- cur.consumed = granted - avail; // grant capacity actually charged
299
- cur.avail = avail;
300
- if (overshoot > BigInt(0)) {
301
- log.info(`grant allotment ${u}: time-aware fold — overshoot ${overshoot} is purchase-paid; granted ${granted} => avail ${avail}`);
258
+ // Raw event stream, folded in code (foldGrantWindow — per-grant buckets, 60-day consumable window,
259
+ // reversals clamp capacity, only jobs create beyond-grants). SQL can't express the bucketed clamp.
260
+ const evRes = await sql `
261
+ select user_id::text as u, coalesce(otoy_created_at, ${GRANT_WINDOW_START}::timestamp) as t,
262
+ (amount_credits * 4 * 1e8)::numeric::text as amt, 'grant' as src, id as gid
263
+ from otoy_grant where user_id = any(${p.userIds}::uuid[])
264
+ union all
265
+ select user_id::text, ${GRANT_WINDOW_START}::timestamp, sum(render_spent_delta)::text, 'grant', null
266
+ from user_grant_spend
267
+ where user_id = any(${p.userIds}::uuid[]) and render_spent_delta > 0 and otoy_grant_id is null
268
+ group by user_id
269
+ union all
270
+ select user_id::text, created_at, (-render_spent_delta)::text, 'grant', null
271
+ from user_grant_spend
272
+ where user_id = any(${p.userIds}::uuid[]) and render_spent_delta > 0 and otoy_grant_id is not null
273
+ union all
274
+ select user_id, completed_at, (-render_amt)::text, 'job', null
275
+ from job
276
+ where user_id = any(${p.userIds}::text[]) and completed_at >= ${GRANT_WINDOW_START}::timestamp
277
+ `.execute(db); // db is always a Kysely trx at runtime
278
+ const byUser = new Map();
279
+ for (const r of evRes.rows) {
280
+ const arr = byUser.get(r.u) ?? [];
281
+ arr.push({
282
+ t: new Date(r.t).getTime(),
283
+ amt: BigInt(Math.round(Number(r.amt))),
284
+ src: r.src,
285
+ grantId: r.gid ?? undefined,
286
+ });
287
+ byUser.set(r.u, arr);
288
+ }
289
+ for (const [u, events] of byUser) {
290
+ const f = foldGrantWindow(events);
291
+ out[u] = {
292
+ userId: u,
293
+ granted: events.reduce((a, e) => (e.src === 'grant' && e.amt > BigInt(0) ? a + e.amt : a), BigInt(0)),
294
+ consumed: f.jobCharged,
295
+ avail: f.avail,
296
+ expired: f.expired,
297
+ fifoGrantId: f.fifoGrantId,
298
+ };
299
+ if (f.beyondGrants > BigInt(0)) {
300
+ log.info(`grant allotment ${u}: ${f.beyondGrants} beyond grants (purchase-paid at the time), ` +
301
+ `${f.expired} expired past the ${GRANT_LOOKBACK_DAYS}d window — avail ${f.avail}`);
302
302
  }
303
303
  }
304
304
  return out;
@@ -1152,16 +1152,13 @@ export const pgClient = (config) => {
1152
1152
  return;
1153
1153
  }
1154
1154
  }
1155
- // FIFO-attribute this spend to the user's oldest OTOY grant that still has room. Stamping
1156
- // otoy_grant_id is what makes getGrantAllotments count it as consumed an unattributed row
1157
- // would leave the grant looking unspent and let it fund a second burn.
1155
+ // FIFO-attribute this spend to the user's oldest LIVE OTOY grant that still has room (the fold
1156
+ // already applies the lookback window). Stamping otoy_grant_id is what shows the draw in the
1157
+ // admin drill-down — an unattributed row would leave the grant looking unspent there.
1158
1158
  let otoyGrantId = p.otoyGrantId;
1159
1159
  if (otoyGrantId === undefined && p.renderSpentDelta < BigInt(0)) {
1160
1160
  const allots = await getGrantAllotments(db.trx, { userIds: [p.userId] }, log);
1161
- const a = allots[p.userId];
1162
- if (a) {
1163
- otoyGrantId = foldGrantAllotment(a.grants, a.consumed, BigInt(0)).fifoGrantId;
1164
- }
1161
+ otoyGrantId = allots[p.userId]?.fifoGrantId;
1165
1162
  }
1166
1163
  const v = {
1167
1164
  user_id: p.userId,