Skip to content

Commit 9ec2b63

Browse files
committed
feat(api): §26's creator reputation figures
Launches, graduations, lifetime volume, trades, holders, and a graduation rate. §26 calls this "a reputation layer that is market-driven without requiring a privileged creator allocation" — so every figure is derived from what the chain did, and there is deliberately no field here an operator could write. THE RATE IS PER MILLE, NOT A FLOAT 2 of 3 as a float is 0.6666666666666666, which every consumer then decides how to round — differently, so two screens disagree about the same creator. §424 keeps financial quantities out of JS floating point; a ratio that gets rendered next to them belongs on the same side of that line. Zero for a creator with no launches, not a division by zero and not "100%". A creator who has launched nothing has not graduated nothing successfully, and either reading is a claim about a record that does not exist. `totalHolders` sums per-market holder counts, so a wallet holding three of a creator's tokens counts three times. That is deliberate — it measures reach across markets, not distinct people — and the field says holders, not people.
1 parent 62a2921 commit 9ec2b63

6 files changed

Lines changed: 159 additions & 1 deletion

File tree

docs/API.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ money already in their wallet, and the claim would revert with `NothingToClaim`
228228
after they paid gas to find out. Without `claims` a creator sees "earned 4.2,
229229
claimable 0" and cannot tell a past withdrawal from a failure.
230230

231+
`stats` is §26's reputation layer: launches, graduations, lifetime volume,
232+
trades, holders, and a graduation rate **per mille**. Every figure is derived
233+
from what the chain did and none of it can be granted, bought or set — which is
234+
the section's whole point, and also why there is no field here an operator could
235+
write. The rate is an integer per mille rather than a float because 2 of 3 as a
236+
float is `0.6666666666666666`, which every consumer then rounds differently.
237+
231238
`feeVault` is returned so the claim a creator signs targets the same contract
232239
the balance came from. A client holding its own vault address could show one
233240
contract's balance over a button that calls another.

packages/database/src/repository.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2167,6 +2167,70 @@ export async function listXStockAssets(db: Db, onlyLaunchable = false): Promise<
21672167
}));
21682168
}
21692169

2170+
export interface CreatorStats {
2171+
readonly launches: number;
2172+
readonly graduated: number;
2173+
/** Lifetime notional across every market they launched, normalized. */
2174+
readonly totalVolume: bigint;
2175+
readonly totalTrades: number;
2176+
readonly totalHolders: number;
2177+
/**
2178+
* Graduated launches per thousand launches.
2179+
*
2180+
* Per mille rather than a float: §424 keeps financial and ratio quantities
2181+
* out of JS floating point, and a rate that arrives as 0.6666666666666666 is
2182+
* a number every consumer has to decide how to round, differently.
2183+
*/
2184+
readonly graduationRatePerMille: number;
2185+
}
2186+
2187+
/**
2188+
* §26's creator reputation figures.
2189+
*
2190+
* "Market-driven reputation without a privileged creator allocation" — every
2191+
* number here is derived from what the chain did, and none of it can be
2192+
* granted, bought or set. That is the whole point of the section: a creator's
2193+
* standing comes from their launches performing, not from a badge.
2194+
*
2195+
* `totalHolders` sums the per-market holder counts, so a wallet holding three
2196+
* of a creator's tokens counts three times. That is deliberate — it measures
2197+
* reach across markets rather than distinct people, and deduplicating it would
2198+
* need a scan of every balance row for a figure nobody would read differently.
2199+
* The name says holders, not people.
2200+
*/
2201+
export async function creatorStats(db: Db, creator: string): Promise<CreatorStats> {
2202+
const row = await db.queryOne<Record<string, unknown>>(
2203+
`SELECT
2204+
COUNT(*)::TEXT AS launches,
2205+
COUNT(*) FILTER (WHERE s.graduated_at_block IS NOT NULL)::TEXT AS graduated,
2206+
COALESCE(SUM(s.holder_count), 0)::TEXT AS holders,
2207+
COALESCE(SUM(s.trade_count), 0)::TEXT AS trades,
2208+
COALESCE((
2209+
SELECT SUM(t.notional) FROM trades t
2210+
WHERE t.market IN (SELECT market FROM markets WHERE creator = $1)
2211+
), 0)::TEXT AS volume
2212+
FROM markets m JOIN market_state s ON s.market = m.market
2213+
WHERE m.creator = $1`,
2214+
[toBytes(creator)],
2215+
);
2216+
2217+
const launches = Number(row?.launches ?? "0");
2218+
const graduated = Number(row?.graduated ?? "0");
2219+
2220+
return {
2221+
launches,
2222+
graduated,
2223+
totalVolume: big(row?.volume ?? "0", "volume"),
2224+
totalTrades: Number(row?.trades ?? "0"),
2225+
totalHolders: Number(row?.holders ?? "0"),
2226+
// Zero for a creator with no launches, not a division by zero and not
2227+
// "100%" — a creator who has launched nothing has not graduated nothing
2228+
// successfully, and either of those readings would be a claim about a
2229+
// record that does not exist.
2230+
graduationRatePerMille: launches === 0 ? 0 : Math.round((graduated * 1_000) / launches),
2231+
};
2232+
}
2233+
21702234
/** Addresses registered as ineligible for Stockback (§323, §324). */
21712235
export async function getExclusions(db: Db, market: string): Promise<`0x${string}`[]> {
21722236
const rows = await db.query<Record<string, unknown>>(

services/api/sim/handlers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ class FakePort implements DataPort {
169169
blockNumber: 870n,
170170
},
171171
],
172+
stats: {
173+
launches: 3,
174+
graduated: 2,
175+
totalVolume: 4_200_000n,
176+
totalTrades: 411,
177+
totalHolders: 96,
178+
graduationRatePerMille: 667,
179+
},
172180
};
173181
getCreator(): CreatorRow | null {
174182
return this.creator;
@@ -873,6 +881,19 @@ console.log("\n--- 10. §221: the creator cockpit ------------------------------
873881
"amounts are serialised as strings",
874882
typeof result.data.claimable[0]?.amount === "string",
875883
);
884+
885+
/*
886+
* §26's reputation layer: market-driven, with nothing an operator can set.
887+
*
888+
* The rate is per mille rather than a float. 2 of 3 as a float is
889+
* 0.6666666666666666, which every consumer then decides how to round —
890+
* differently — and §424 keeps ratio quantities out of JS floating point for
891+
* the same reason it keeps amounts out.
892+
*/
893+
check("launches are counted", result.data.stats.launches === 3);
894+
check("graduations too", result.data.stats.graduated === 2);
895+
check("and the rate is an integer per mille", result.data.stats.graduationRatePerMille === 667);
896+
check("lifetime volume crosses the wire as a string", result.data.stats.totalVolume === "4200000");
876897
}
877898

878899
{
@@ -886,6 +907,14 @@ console.log("\n--- 10. §221: the creator cockpit ------------------------------
886907
check("a creator with no launches is not an error", empty.ok);
887908
check("and gets an empty cockpit", empty.ok && empty.data.launches.length === 0);
888909

910+
// Zero, not a division by zero and not "100%". A creator who has launched
911+
// nothing has not graduated nothing successfully, and either reading would be
912+
// a claim about a record that does not exist.
913+
check(
914+
"with a graduation rate of zero rather than a divide by zero",
915+
empty.ok && empty.data.stats.graduationRatePerMille === 0,
916+
);
917+
889918
const bad = handleCreator(port, "0x123");
890919
check("a malformed address is refused by name", !bad.ok && bad.code === "INVALID_ADDRESS");
891920
check("and the refusal still carries freshness", bad.freshness !== undefined);

services/api/src/handlers.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ export interface CreatorRow {
163163
timestamp: number;
164164
blockNumber: bigint;
165165
}[];
166+
/** §26's reputation figures. Derived from the chain; none can be granted. */
167+
readonly stats: {
168+
readonly launches: number;
169+
readonly graduated: number;
170+
readonly totalVolume: bigint;
171+
readonly totalTrades: number;
172+
readonly totalHolders: number;
173+
readonly graduationRatePerMille: number;
174+
};
166175
}
167176

168177
export interface CandleBar {
@@ -700,6 +709,23 @@ export interface CreatorResponse {
700709
timestamp: number;
701710
blockNumber: string;
702711
}[];
712+
/**
713+
* §26's reputation layer.
714+
*
715+
* Every figure is derived from what the chain did, and none of it can be
716+
* granted, bought or set. That is the section's whole point: standing comes
717+
* from launches performing, not from a badge — which is also why there is no
718+
* field here an operator could write.
719+
*/
720+
readonly stats: {
721+
readonly launches: number;
722+
readonly graduated: number;
723+
readonly totalVolume: string;
724+
readonly totalTrades: number;
725+
readonly totalHolders: number;
726+
/** Per mille, not a float. See the repository for why. */
727+
readonly graduationRatePerMille: number;
728+
};
703729
}
704730

705731
/**
@@ -731,6 +757,14 @@ export function handleCreator(port: DataPort, address: string): ApiResult<Creato
731757
claimable: [],
732758
accrued: [],
733759
claims: [],
760+
stats: {
761+
launches: 0,
762+
graduated: 0,
763+
totalVolume: "0",
764+
totalTrades: 0,
765+
totalHolders: 0,
766+
graduationRatePerMille: 0,
767+
},
734768
});
735769
}
736770

@@ -772,6 +806,14 @@ export function handleCreator(port: DataPort, address: string): ApiResult<Creato
772806
timestamp: c.timestamp,
773807
blockNumber: c.blockNumber.toString(),
774808
})),
809+
stats: {
810+
launches: row.stats.launches,
811+
graduated: row.stats.graduated,
812+
totalVolume: row.stats.totalVolume.toString(),
813+
totalTrades: row.stats.totalTrades,
814+
totalHolders: row.stats.totalHolders,
815+
graduationRatePerMille: row.stats.graduationRatePerMille,
816+
},
775817
});
776818
}
777819

services/api/src/port.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
listMarketsByCreator,
3737
creatorAccruals,
3838
listFeeClaims,
39+
creatorStats,
3940
countMarkets as dbCountMarkets,
4041
listHoldings,
4142
accountStockback,
@@ -558,10 +559,11 @@ export class PostgresPort implements DataPort {
558559
* a zero balance, so nothing is missed by asking only about the ones indexed.
559560
*/
560561
async loadCreator(address: string): Promise<void> {
561-
const [views, accruals, claims] = await Promise.all([
562+
const [views, accruals, claims, stats] = await Promise.all([
562563
listMarketsByCreator(this.db, address),
563564
creatorAccruals(this.db, address),
564565
listFeeClaims(this.db, address),
566+
creatorStats(this.db, address),
565567
]);
566568

567569
const vault = await this.resolveFeeVault();
@@ -623,6 +625,7 @@ export class PostgresPort implements DataPort {
623625
timestamp: c.timestamp,
624626
blockNumber: c.blockNumber,
625627
})),
628+
stats,
626629
});
627630
}
628631

tests/e2e/stack.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,6 +1439,19 @@ try {
14391439
drift < oneRawUnit * tradeCount,
14401440
);
14411441

1442+
/*
1443+
* §26's reputation figures, over the real chain.
1444+
*
1445+
* One launch, graduated, so the rate is exactly 1000 per mille — a number
1446+
* that can be checked rather than compared against whatever the code
1447+
* produced.
1448+
*/
1449+
const cstats = cockpit?.stats as Record<string, unknown>;
1450+
check("the creator's launches are counted", cstats?.launches === 1);
1451+
check("and their graduation", cstats?.graduated === 1);
1452+
check("giving a rate of 1000 per mille", cstats?.graduationRatePerMille === 1_000);
1453+
check("with lifetime volume from real trades", BigInt(String(cstats?.totalVolume)) > 0n);
1454+
14421455
check(
14431456
"graduation progress is reported per launch",
14441457
launches.every((l) => (l.graduationProgressBps as Record<string, unknown>)?.value !== undefined),

0 commit comments

Comments
 (0)