@render-foundation/utils 0.0.237 → 0.0.239

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.
@@ -21,6 +21,7 @@ export const ENTITY_EPOCH_INFO_TABLE = 'entity_epoch_info';
21
21
  export const POLYGON_UPGRADE_TABLE = 'polygon_upgrade';
22
22
  export const CURRENT_USER_GRANT_SPEND_TABLE = 'current_user_grant_spend';
23
23
  export const USER_GRANT_SPEND_TABLE = 'user_grant_spend';
24
+ export const OTOY_GRANT_TABLE = 'otoy_grant';
24
25
  export const GRANT_BURN_ID_TABLE = 'grant_burn_id';
25
26
  export const GRANT_BURN_TABLE = 'grant_burn';
26
27
  export const DISPERSED_SETTLEMENT_TABLE = 'dispersed_settlement';
@@ -28,6 +29,39 @@ export const DISPERSED_BURN_CORRECTION_TABLE = 'dispersed_burn_correction';
28
29
  BigInt.prototype.toJSON = function () {
29
30
  return this.toString();
30
31
  };
32
+ // EUR -> render credits. OTOY issues grants in EUR (otoy_grant.amount_credits holds the raw OTOY
33
+ // confirmed_amount = EUR, despite the column name); 1 render credit = EUR 0.25, so credits = EUR x 4.
34
+ // job.render_amt / user_grant_spend.render_spent_delta are credits x 1e8.
35
+ export const EUR_TO_CREDITS = BigInt(4);
36
+ 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;
55
+ let fifoGrantId;
56
+ for (const g of grants) {
57
+ if (drawn < g.amountCredits) {
58
+ fifoGrantId = g.id;
59
+ break;
60
+ }
61
+ drawn -= g.amountCredits;
62
+ }
63
+ return { granted, consumed, avail, fifoGrantId };
64
+ };
31
65
  import { begin } from './base';
32
66
  import moment from 'moment';
33
67
  pg.types.setTypeParser(1114, (str) => moment.utc(str).toDate());
@@ -150,6 +184,73 @@ export const pgClient = (config) => {
150
184
  }
151
185
  : undefined;
152
186
  };
187
+ // Spendable grant per user, straight off otoy_grant (FIFO), net of what's already been spent or
188
+ // already settled by a burn correction. See GrantAllotment for the units + the double-spend guard.
189
+ const getGrantAllotments = async (db, p, log = consoleLogger) => {
190
+ const out = {};
191
+ if (!p.userIds.length)
192
+ 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,
239
+ });
240
+ }
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}`);
250
+ }
251
+ }
252
+ return out;
253
+ };
153
254
  const updateTotalRndr = async (db, p, totalRndr, savedAt, log) => {
154
255
  const res = (await db
155
256
  .updateTable(EPOCH_TABLE)
@@ -999,11 +1100,23 @@ export const pgClient = (config) => {
999
1100
  return;
1000
1101
  }
1001
1102
  }
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.
1106
+ let otoyGrantId = p.otoyGrantId;
1107
+ if (otoyGrantId === undefined && p.renderSpentDelta < BigInt(0)) {
1108
+ 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
+ }
1113
+ }
1002
1114
  const v = {
1003
1115
  user_id: p.userId,
1004
1116
  render_spent_delta: p.renderSpentDelta,
1005
1117
  grant_burn_id: p.grantBurnId,
1006
1118
  job_id: p.jobId,
1119
+ otoy_grant_id: otoyGrantId,
1007
1120
  };
1008
1121
  const res = await db.trx
1009
1122
  .insertInto(USER_GRANT_SPEND_TABLE)
@@ -1348,6 +1461,103 @@ export const pgClient = (config) => {
1348
1461
  }
1349
1462
  return burns;
1350
1463
  },
1464
+ async fetchBurnsPage(db, f, log = consoleLogger) {
1465
+ const lastId = f.cursor ? atob(f.cursor) : undefined;
1466
+ let q = db.trx
1467
+ .selectFrom('burn')
1468
+ .selectAll('burn')
1469
+ .innerJoin('sol_tx', 'sol_tx.id', 'burn.sol_tx_id')
1470
+ .select(['sol_tx.executed_at', 'sol_tx.sig']);
1471
+ if (!f.inclManual) {
1472
+ q = q.where('burn.usdc_spent', '>', String(0));
1473
+ }
1474
+ if (f.executedAfter) {
1475
+ q = q.where('sol_tx.executed_at', '>=', f.executedAfter);
1476
+ }
1477
+ if (f.executedBefore) {
1478
+ q = q.where('sol_tx.executed_at', '<=', f.executedBefore);
1479
+ }
1480
+ if (lastId) {
1481
+ q = q.where('burn.id', '>', lastId);
1482
+ }
1483
+ const limit = f.limit ?? 1000;
1484
+ q = q.limit(limit).orderBy('burn.id');
1485
+ const comp = q.compile();
1486
+ const { rows } = await timedQuery(pgPool, comp.sql, comp.parameters, 'fetchBurnsPage', log);
1487
+ const burns = rows.map((r) => ({
1488
+ jobs: [],
1489
+ id: Number(r.id),
1490
+ renderToUsdc: r.render_to_usdc,
1491
+ solTx: r.sig,
1492
+ executedAt: r.executed_at,
1493
+ pricedAt: r.priced_at,
1494
+ usdcSpent: BigInt(r.usdc_spent ?? 0),
1495
+ quoteAmt: BigInt(r.quote_amt ?? 0),
1496
+ burned: BigInt(r.burned),
1497
+ eurToUsdc: r.eur_to_usdc,
1498
+ markedBurnedAt: r.marked_burned_at,
1499
+ tags: r.tags ? r.tags.split(',') : [],
1500
+ }));
1501
+ return {
1502
+ burns,
1503
+ // Only hand back a cursor on a full page; a short page means we're done.
1504
+ cursor: burns.length === limit
1505
+ ? btoa(String(burns[burns.length - 1].id))
1506
+ : undefined,
1507
+ };
1508
+ },
1509
+ async fetchBurnJobs(db, burnId, log = consoleLogger) {
1510
+ const rows = await db.trx
1511
+ .selectFrom('job')
1512
+ .selectAll('job')
1513
+ .where('burn_id', '=', String(burnId))
1514
+ .execute();
1515
+ log.debug(`fetchBurnJobs burn ${burnId} -> ${rows.length} jobs`);
1516
+ return rows.map((r) => ({
1517
+ id: Number(r.id),
1518
+ completedAt: r.completed_at,
1519
+ rndrUsed: BigInt(r.render_amt),
1520
+ obhUsed: r.obh_used ? Number(r.obh_used) : undefined,
1521
+ }));
1522
+ },
1523
+ async fetchDailyBurnStats(db, f, log = consoleLogger) {
1524
+ // sol_tx.executed_at is `timestamp WITHOUT time zone` already holding UTC wall-clock, so truncate it
1525
+ // DIRECTLY. Do not write `executed_at at time zone 'UTC'`: that promotes it to timestamptz, after which
1526
+ // date_trunc truncates in the Postgres session's TimeZone — measured against prod, a Tokyo session moved
1527
+ // the bucket boundary to 15:00Z and a Los_Angeles session changed the bucket COUNT (783 vs 786).
1528
+ // Truncating the naive column is pure arithmetic and identical under every session timezone, and
1529
+ // to_char hands back a plain 'YYYY-MM-DD' so node-postgres has no Date to decode in local time.
1530
+ const dayExpr = sql `to_char(date_trunc('day', sol_tx.executed_at), 'YYYY-MM-DD')`;
1531
+ let q = db.trx
1532
+ .selectFrom('burn')
1533
+ .innerJoin('sol_tx', 'sol_tx.id', 'burn.sol_tx_id')
1534
+ .select(({ fn }) => [
1535
+ dayExpr.as('day'),
1536
+ fn.sum('burn.burned').as('render_burned'),
1537
+ fn.sum('burn.usdc_spent').as('usdc_spent'),
1538
+ fn.count('burn.id').as('burn_count'),
1539
+ ])
1540
+ .groupBy(sql `date_trunc('day', sol_tx.executed_at)`)
1541
+ .orderBy(sql `date_trunc('day', sol_tx.executed_at)`);
1542
+ if (!f.inclManual) {
1543
+ q = q.where('burn.usdc_spent', '>', String(0));
1544
+ }
1545
+ if (f.executedAfter) {
1546
+ q = q.where('sol_tx.executed_at', '>=', f.executedAfter);
1547
+ }
1548
+ if (f.executedBefore) {
1549
+ q = q.where('sol_tx.executed_at', '<=', f.executedBefore);
1550
+ }
1551
+ const comp = q.compile();
1552
+ const { rows } = await timedQuery(pgPool, comp.sql, comp.parameters, 'fetchDailyBurnStats', log);
1553
+ log.info(`fetchDailyBurnStats len ${rows.length}`);
1554
+ return rows.map((r) => ({
1555
+ day: r.day,
1556
+ renderBurned: BigInt(r.render_burned ?? 0),
1557
+ usdcSpent: BigInt(r.usdc_spent ?? 0),
1558
+ burnCount: Number(r.burn_count ?? 0),
1559
+ }));
1560
+ },
1351
1561
  async fetchBurnsAndGrants(db, f, log = consoleLogger) {
1352
1562
  // Build a unified query to get all transactions with both buy burns and grant burns
1353
1563
  let baseQuery = db.trx
@@ -1751,7 +1961,6 @@ export const pgClient = (config) => {
1751
1961
  .select([
1752
1962
  'tx_signature',
1753
1963
  'claim_uuid',
1754
- 'obligation_uuids',
1755
1964
  'settled_amount',
1756
1965
  'burned_amount',
1757
1966
  'usdc_spent',
@@ -1765,7 +1974,6 @@ export const pgClient = (config) => {
1765
1974
  return rows.map((r) => ({
1766
1975
  txSignature: r.tx_signature,
1767
1976
  claimUuid: r.claim_uuid,
1768
- obligationUuids: r.obligation_uuids,
1769
1977
  settledAmount: r.settled_amount,
1770
1978
  burnedAmount: r.burned_amount,
1771
1979
  usdcSpent: r.usdc_spent ?? null,
@@ -1979,6 +2187,9 @@ export const pgClient = (config) => {
1979
2187
  async getCurrentUserGrantSpend(db, f, log = consoleLogger) {
1980
2188
  return await getCurrentUserGrantSpend(db.trx, f, log);
1981
2189
  },
2190
+ async getGrantAllotments(db, f, log = consoleLogger) {
2191
+ return await getGrantAllotments(db.trx, f, log);
2192
+ },
1982
2193
  async deleteAll(log = consoleLogger) {
1983
2194
  if (config.database.includes('render')) {
1984
2195
  throw new Error('cannot nuke from deployed database. call from tests only');