clockwork-press 0.0.1 → 0.2.1

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.
package/README.md CHANGED
@@ -1,8 +1,20 @@
1
- # clockwork-press
1
+ # clockwork
2
2
 
3
3
  your fees, on a clock, with receipts.
4
4
 
5
- the fee machine for tokens on robinhood chain: it claims a token's creator fees, splits them, burns in slices on a
6
- fifteen-minute clock, and prints every hash. it runs in your own repository on your own wallet. it never holds a key.
5
+ the fee machine for tokens on robinhood chain: claims, splits, burns, a treasury that only grows, telegram cards,
6
+ a status page, holder tools. no custody: the machine runs in your own repo on your own wallet; every move is a hash.
7
7
 
8
- this version only reserves the name. the first working release ships with client zero, gmerald.xyz.
8
+ - `packages/press` · the machine, published as `clockwork-press` on npm
9
+ - `template` · what a client repo holds: `clockwork.json` and one workflow
10
+ - `docs/PLAN.md` · the plan
11
+
12
+ status: week one. the press from gmerald.xyz is being generalized here, one decision at a time.
13
+
14
+ this week, after a reread of the pons v2 docs: the machine sweeps before it claims (fees sit on the curve or the
15
+ hook until a sweep, so an escrow balance of zero never meant nothing was owed) and claims from both escrow
16
+ ledgers, native and per-token, so eth pairs and released buyback vests work. it reads the creator fee recipient and
17
+ any pending takeover every run and says so when fees point somewhere else. the six trading knobs fold into one
18
+ control, the pace: gentle, steady or once; the raw rules stay under advanced. there is one default split
19
+ everywhere, 45 / 45 / 0 / 10, the ops wallet is optional, and the service wallet lives in the package, not in
20
+ any client's file.
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // clockwork <init|press|dry|doctor|claimcheck|launchcheck|handback <address>|holders|tgcheck|log|buys|quotecheck>
3
+ // (reads clockwork.json from the working directory, or the file CLOCKWORK_CONFIG names)
4
+ // runs the built machine in dist/, plain node, no tsx at runtime: a client's clock executes exactly the
5
+ // javascript that was published, and the package carries no compiler.
6
+ import { existsSync } from 'node:fs';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ const entry = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'index.js');
11
+ if (!existsSync(entry)) { console.error('clockwork: dist/index.js is missing. build first: npm run build'); process.exit(1); }
12
+ // index.js reads the job from process.argv[2], which is already in place when node runs this file directly
13
+ await import(pathToFileURL(entry).href);
package/dist/abis.js ADDED
@@ -0,0 +1,73 @@
1
+ import { parseAbi } from 'viem';
2
+ export const erc20Abi = parseAbi([
3
+ 'function balanceOf(address) view returns (uint256)',
4
+ 'function totalSupply() view returns (uint256)',
5
+ 'function decimals() view returns (uint8)',
6
+ 'function symbol() view returns (string)',
7
+ 'function approve(address spender, uint256 amount) returns (bool)',
8
+ 'function transfer(address to, uint256 amount) returns (bool)',
9
+ 'function burn(uint256 amount)',
10
+ ]);
11
+ // the recipient functions live on the factory, which owns the LaunchedToken record. verified on chain
12
+ // 2026-09-06 against gmerald (0x3E4E…9458): pendingCreatorFeeRecipient(token) returns (0x0, 0, 0) from the
13
+ // factory and reverts with empty data on the hook and the escrow; transferCreatorFeeRecipient(token, 0xdead)
14
+ // simulated from a made-up account reverts NotCreatorFeeRecipient (0xb9f93944) on the factory only.
15
+ export const factoryAbi = parseAbi([
16
+ 'struct LaunchedToken { address token; address curve; address deployer; address creatorFeeRecipient; address pairToken; uint256 graduationThreshold; uint24 poolFee; int24 tickSpacing; uint16 creatorTaxBps; bool buybackEnabled; uint8 phase; uint256 sweptQuote; uint256 sweptTokens; uint256 sweptAt; bool exists; }',
17
+ 'function getLaunchedToken(address token) view returns (LaunchedToken)',
18
+ 'function approvedPairTokens(address pairToken) view returns (bool)',
19
+ 'function pairTokenEconomics(address pairToken) view returns (uint256 phantomQuote, uint256 graduationThreshold, uint8 decimals)',
20
+ 'function pendingCreatorFeeRecipient(address token) view returns (address to, uint256 effectiveAt, uint256 expiresAt)',
21
+ 'function transferCreatorFeeRecipient(address token, address newRecipient)',
22
+ 'error NotCreatorFeeRecipient()',
23
+ 'error TimelockNotElapsed()',
24
+ 'error TimelockExpired()',
25
+ ]);
26
+ export const curveAbi = parseAbi([
27
+ 'function getReserves() view returns (uint256 quoteReserve, uint256 tokenReserve)',
28
+ 'function sellableTokens() view returns (uint256)',
29
+ 'function readyToGraduate() view returns (bool)',
30
+ 'function feeBps() view returns (uint256)',
31
+ 'function creatorTaxBps() view returns (uint256)',
32
+ 'function currentSnipeTaxBps(address recipient) view returns (uint256)',
33
+ 'function quoteFeeBalance() view returns (uint256)',
34
+ 'function creatorTaxBalance() view returns (uint256)',
35
+ 'function buy(uint256 quoteIn, uint256 minTokensOut, address recipient) payable returns (uint256 tokensOut)',
36
+ 'function realQuoteReserve() view returns (uint256)',
37
+ 'function graduationThreshold() view returns (uint256)',
38
+ 'function sweepFees(uint256 minBuybackTokensOut)',
39
+ 'error InternalSwapRequiresOperator()',
40
+ 'error CurveGraduated()',
41
+ ]);
42
+ // the meme hook: fees accrue here per pool and per currency after graduation, until a sweep moves them.
43
+ // poolId is the uniswap v4 key hash (see poolIdFor in pons.ts). a creator's sweepPoolFees reverts
44
+ // InternalSwapRequiresOperator (0x31cdb504, seen live) whenever launch-token fees need converting.
45
+ export const hookAbi = parseAbi([
46
+ 'function pendingFees(bytes32 poolId, address currency) view returns (uint256)',
47
+ 'function pendingCreatorTax(bytes32 poolId, address currency) view returns (uint256)',
48
+ 'function sweepPoolFees(bytes32 poolId, uint256 minConversionQuoteOut, uint256 minBuybackTokensOut)',
49
+ 'error InternalSwapRequiresOperator()',
50
+ ]);
51
+ // two ledgers: native (balanceOf / claim) for eth-quoted launches, per-token (balanceOfToken / claimToken)
52
+ // for custom pairs. a released buyback vest credits the per-token ledger under the launch token itself.
53
+ export const escrowAbi = parseAbi([
54
+ 'function balanceOf(address recipient) view returns (uint256)',
55
+ 'function balanceOfToken(address recipient, address token) view returns (uint256)',
56
+ 'function claim()',
57
+ 'function claimToken(address token)',
58
+ ]);
59
+ // the buyback vault: bought-back supply vesting over five years from a weighted start.
60
+ export const vaultAbi = parseAbi([
61
+ 'function releasable(address token) view returns (uint256)',
62
+ 'function release(address token) returns (uint256 released)',
63
+ 'function totalLocked(address token) view returns (uint256)',
64
+ 'function totalReleased(address token) view returns (uint256)',
65
+ 'function vestedAmount(address token) view returns (uint256)',
66
+ 'function vestingStart(address token) view returns (uint256)',
67
+ 'function VESTING_DURATION() view returns (uint256)',
68
+ 'error NotVestBeneficiary()',
69
+ ]);
70
+ export const feedAbi = parseAbi([
71
+ 'function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)',
72
+ 'function decimals() view returns (uint8)',
73
+ ]);
package/dist/chain.js ADDED
@@ -0,0 +1,24 @@
1
+ import { createPublicClient, createWalletClient, defineChain, fallback, http } from 'viem';
2
+ import { privateKeyToAccount } from 'viem/accounts';
3
+ import { cfg } from './env.js';
4
+ export const robinhood = defineChain({
5
+ id: 4663,
6
+ name: 'Robinhood Chain',
7
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
8
+ rpcUrls: { default: { http: [cfg.rpcUrl] } },
9
+ });
10
+ // the public rpc rate-limits shared runners ("Too Many Requests"): back off and retry for a couple of minutes
11
+ // before giving up, and use a second rpc if one is configured (RPC_URL_2).
12
+ const opts = { retryCount: 6, retryDelay: 1500, timeout: 20_000 };
13
+ const transport = () => (cfg.rpcUrl2 ? fallback([http(cfg.rpcUrl, opts), http(cfg.rpcUrl2, opts)]) : http(cfg.rpcUrl, opts));
14
+ export const pub = createPublicClient({ chain: robinhood, transport: transport() });
15
+ export const account = () => {
16
+ if (!cfg.key)
17
+ throw new Error('MACHINE_WALLET_KEY not set');
18
+ const k = cfg.key.startsWith('0x') ? cfg.key : `0x${cfg.key}`;
19
+ const a = privateKeyToAccount(k);
20
+ if (a.address.toLowerCase() !== String(cfg.pressWallet).toLowerCase())
21
+ throw new Error(`the key is for ${a.address}, but clockwork.json names ${cfg.pressWallet} as the machine wallet; refusing to run`);
22
+ return a;
23
+ };
24
+ export const wallet = () => createWalletClient({ account: account(), chain: robinhood, transport: transport() });
package/dist/config.js ADDED
@@ -0,0 +1,62 @@
1
+ export const SERVICE_MIN_BPS = 1000;
2
+ export const ZERO = '0x0000000000000000000000000000000000000000';
3
+ // the clockwork wallet. pinned here, in the package, not in any client's config: the founder creates it and it is set once.
4
+ export const CLOCKWORK_ADDRESS = '0x6EA62Bd07FE08C7491543d495B42F6dA7ad298D0'; // the founder's service wallet, created 2026-09-06; a plain wallet, never a contract
5
+ // the house does not pay itself. detected by token, so no client file can claim the exemption by naming an address.
6
+ export const HOUSE_TOKENS = ['0x3e4e7bbee9a7e5fbedabeea66313c8f636999458'];
7
+ export const isHouse = (token) => HOUSE_TOKENS.includes(String(token).toLowerCase());
8
+ const isAddr = (a) => /^0x[0-9a-fA-F]{40}$/.test(String(a ?? ''));
9
+ const isZero = (a) => /^0x0{40}$/i.test(String(a ?? ''));
10
+ export function validate(c) {
11
+ const bad = (m) => { throw new Error(`clockwork.json: ${m}`); };
12
+ if (c?.version !== 1)
13
+ bad('version must be 1');
14
+ for (const k of ['token', 'pair', 'launch', 'wallets', 'split', 'schedule', 'slices', 'burn', 'claim', 'telegram', 'site', 'brand'])
15
+ if (!c[k])
16
+ bad(`missing "${k}"`);
17
+ if (!isAddr(c.token.address) || isZero(c.token.address))
18
+ bad('token.address is not an address');
19
+ const s = c.split;
20
+ if (s.burnBps + s.treasuryBps + s.opsBps + s.serviceBps !== 10000)
21
+ bad('split must sum to 10000 bps');
22
+ if (s.serviceBps < SERVICE_MIN_BPS && !isHouse(c.token.address))
23
+ bad(`serviceBps must be at least ${SERVICE_MIN_BPS}`);
24
+ // a paying client's run would transfer to 0x0 and revert after the burn and the treasury legs: refuse before the first tick
25
+ if (s.serviceBps > 0 && CLOCKWORK_ADDRESS === ZERO)
26
+ bad('the service wallet is not pinned in this build of clockwork-press; update the package');
27
+ // the zero address is a valid-looking hex string that destroys a native pair and reverts an erc20 transfer
28
+ // after the buy leg has landed; a template left half-filled fails here, never on chain.
29
+ for (const w of ['machine', 'treasury']) {
30
+ if (!isAddr(c.wallets[w]))
31
+ bad(`wallets.${w} is not an address`);
32
+ if (isZero(c.wallets[w]))
33
+ bad(`wallets.${w} is the zero address; fill it in`);
34
+ }
35
+ if (String(c.wallets.machine).toLowerCase() === String(c.wallets.treasury).toLowerCase())
36
+ bad('wallets.treasury must not be the machine wallet (the machine burns what it holds)');
37
+ if (s.opsBps > 0 && !isAddr(c.wallets.ops))
38
+ bad('wallets.ops is needed when split.opsBps is above zero');
39
+ if (c.wallets.ops !== undefined && (!isAddr(c.wallets.ops) || isZero(c.wallets.ops)))
40
+ bad('wallets.ops is not an address');
41
+ if (c.pair.native) {
42
+ if (!/^0x0{40}$/i.test(String(c.pair.address)))
43
+ bad('pair.native launches use the zero address');
44
+ }
45
+ else if (!isAddr(c.pair.address))
46
+ bad('pair.address is not an address');
47
+ const r = c.slices.rule;
48
+ if (!r?.kind)
49
+ bad('slices.rule needs a kind');
50
+ if (r.kind === 'pace' && !['gentle', 'steady', 'once'].includes(r.pace))
51
+ bad('slices.rule.pace must be gentle, steady or once');
52
+ if (c.buyback && !['burn', 'hold'].includes(c.buyback.vest))
53
+ bad('buyback.vest must be burn or hold');
54
+ if (![5, 15, 30, 60].includes(c.schedule.cadenceMin))
55
+ bad('schedule.cadenceMin must be 5, 15, 30 or 60');
56
+ if (c.claim.floor != null && !(Number(c.claim.floor) >= 0))
57
+ bad('claim.floor must be a number of the pairing asset, 0 or more');
58
+ if (c.claim.atLeastEveryHours != null && !(Number(c.claim.atLeastEveryHours) >= 1))
59
+ bad('claim.atLeastEveryHours must be 1 or more');
60
+ if (c.launch.kind === 'pons-v2' && !(c.launch.feeEscrow && c.launch.factory))
61
+ bad('pons-v2 launches need launch.factory and launch.feeEscrow');
62
+ }
package/dist/env.js ADDED
@@ -0,0 +1,86 @@
1
+ // clockwork.json is the machine's configuration and the client owns it. The only secret is the machine
2
+ // wallet's key (MACHINE_WALLET_KEY). Everything the press used to read from environment variables comes
3
+ // from the file, mapped onto the same names, so the machine's code stays the code that ran gmerald.xyz.
4
+ import { readFileSync, mkdirSync } from 'node:fs';
5
+ import { resolve, dirname } from 'node:path';
6
+ import { validate, isHouse, SERVICE_MIN_BPS, CLOCKWORK_ADDRESS } from './config.js';
7
+ const configPath = resolve(process.env.CLOCKWORK_CONFIG || 'clockwork.json');
8
+ let c;
9
+ try {
10
+ c = JSON.parse(readFileSync(configPath, 'utf8'));
11
+ }
12
+ catch (e) {
13
+ throw new Error(`cannot read ${configPath}: ${e.message}`);
14
+ }
15
+ validate(c);
16
+ const opt = (k, d = '') => { const v = process.env[k]; return v === undefined || v === '' ? d : v; };
17
+ const rule = c.slices.rule;
18
+ const dataDir = resolve(dirname(configPath), c.data?.dir || 'data');
19
+ mkdirSync(dataDir, { recursive: true });
20
+ const house = isHouse(c.token.address);
21
+ // the pace rule is the wizard's one control; it maps onto the raw rules the press already knows how to run.
22
+ // gentle spreads a claim over a day, steady over six hours, once eats the float in one slice.
23
+ const pace = rule.kind === 'pace' ? rule.pace : 'custom';
24
+ const spreadHours = rule.kind === 'spread' ? rule.hours : pace === 'gentle' ? 24 : pace === 'steady' ? 6 : 0;
25
+ const fractionBps = rule.kind === 'fraction' ? rule.bps : rule.kind === 'oneclip' || pace === 'once' ? 10000 : 1667;
26
+ const pairNative = !!c.pair.native;
27
+ export const cfg = {
28
+ configPath, configDir: dirname(configPath),
29
+ rpcUrl: c.chain.rpc || 'https://rpc.mainnet.chain.robinhood.com',
30
+ rpcUrl2: c.chain.rpc2 || opt('RPC_URL_2'),
31
+ explorer: c.chain.explorer || 'https://robinhoodchain.blockscout.com',
32
+ factory: (c.launch.factory || '0x7eD598BcEf8bd9Edd8C97A195C6d13f40801EC7e'),
33
+ // the pairing asset. the machine's code still calls it "gme" in places; it is whatever the launch is priced in.
34
+ // a native launch is quoted in eth: the pair address is the zero address and the escrow keeps it on its native ledger.
35
+ gme: c.pair.address, pair: c.pair.address, pairSymbol: pairNative ? 'ETH' : c.pair.symbol, pairDecimals: pairNative ? 18 : c.pair.decimals ?? 18, pairNative,
36
+ token: c.token.address, tokenSymbol: c.token.symbol, tokenName: c.token.name, totalSupply: c.token.supply ?? 1_000_000_000,
37
+ escrow: (c.launch.feeEscrow || ''), locker: (c.launch.locker || ''), claimMode: c.claim.mode, claimFloor: Number(c.claim.floor ?? 5), claimEveryHours: Number(c.claim.atLeastEveryHours ?? 24),
38
+ hook: (c.launch.hook || '0xE5e702641Ea86F4ae6cC3cDaeD2B886f976Be044'), vault: (c.launch.vault || '0x42df2a798f82289E177311362e8f5ccC45c1219c'),
39
+ vestMode: c.buyback?.vest ?? 'burn', paceWord: pace,
40
+ // ops is '' when the client has no ops wallet; the ops leg then holds (and validate() refuses opsBps > 0 without one)
41
+ stash: c.wallets.treasury, ops: c.wallets.ops || '', pressWallet: c.wallets.machine, service: CLOCKWORK_ADDRESS, house,
42
+ key: opt('MACHINE_WALLET_KEY', opt('PRESS_WALLET_KEY')),
43
+ dry: opt('CLOCKWORK_DRY_RUN') === '1' || opt('PRESS_DRY_RUN') === '1',
44
+ slippageBps: BigInt(c.slices.maxSlippageBps ?? 300), minPressGme: String(c.slices.minSlice ?? 0.05),
45
+ burnBps: BigInt(c.split.burnBps), stashBps: BigInt(c.split.treasuryBps), opsBps: BigInt(c.split.opsBps),
46
+ serviceBps: BigInt(house ? c.split.serviceBps : Math.max(c.split.serviceBps, SERVICE_MIN_BPS)), BPS: 10000n,
47
+ pegFeed: (c.pair.usdFeed || ''), pegToleranceBps: 0, opsForce: false, v4SwapEnabled: true,
48
+ pressFractionBps: BigInt(fractionBps),
49
+ pressSliceGme: rule.kind === 'fixed' ? String(rule.amount) : '0',
50
+ pressSliceUsd: rule.kind === 'usd' ? rule.floorUsd : 0,
51
+ pressSpreadHours: spreadHours,
52
+ napUntil: c.schedule.napUntil || '', quietHours: c.schedule.quietHoursUtc, weekends: c.schedule.weekends ?? true,
53
+ dipMode: !!c.slices.dip?.on, dipBandBps: c.slices.dip?.bandBps ?? 1000, dipMultiplier: BigInt(Math.max(1, Math.round(c.slices.dip?.multiplier ?? 2))),
54
+ quoteSanityBps: c.slices.quoteSanityBps ?? 1500, cadenceMin: c.schedule.cadenceMin, refreshMin: 55,
55
+ minBuyUsd: String(c.telegram.buyFloorUsd ?? 50), buyStepUsd: '100',
56
+ tgToken: c.telegram.mode === 'off' ? '' : opt('TELEGRAM_BOT_TOKEN'), tgChat: c.telegram.chatId || opt('TELEGRAM_CHAT_ID'), tgMode: c.telegram.mode, cards: c.telegram.cards,
57
+ gradSeedGme: '0', siteDir: dataDir,
58
+ words: { claim: c.brand.claimWord || 'claim', slice: c.brand.burnWord || 'burn', treasury: c.brand.treasuryWord || 'treasury' },
59
+ brandName: c.brand.name, emoji: c.brand.emoji || '⚙️', siteUrl: c.site.url || '', art: c.brand.art || {}, seatThreshold: c.site.seatThreshold ?? 250000,
60
+ };
61
+ export const launched = () => true;
62
+ // A nap: a one-off nap until a time, quiet hours, or weekends off. The claim scanner still runs.
63
+ export function napping() {
64
+ const now = new Date();
65
+ if (cfg.napUntil && now.getTime() < new Date(cfg.napUntil).getTime())
66
+ return { yes: true, until: cfg.napUntil };
67
+ const d = now.getUTCDay();
68
+ if (!cfg.weekends && (d === 0 || d === 6)) {
69
+ const m = new Date(now);
70
+ m.setUTCDate(m.getUTCDate() + (d === 6 ? 2 : 1));
71
+ m.setUTCHours(0, 0, 0, 0);
72
+ return { yes: true, until: m.toISOString() };
73
+ }
74
+ if (cfg.quietHours) {
75
+ const [a, b] = cfg.quietHours, h = now.getUTCHours();
76
+ const inside = a <= b ? h >= a && h < b : h >= a || h < b;
77
+ if (inside) {
78
+ const u = new Date(now);
79
+ if (h >= b)
80
+ u.setUTCDate(u.getUTCDate() + 1);
81
+ u.setUTCHours(b, 0, 0, 0);
82
+ return { yes: true, until: u.toISOString() };
83
+ }
84
+ }
85
+ return { yes: false, until: '' };
86
+ }
@@ -0,0 +1,85 @@
1
+ // Where the stash ranks among holders of tokenized gme. The explorer blocks automated reads, so the
2
+ // bot keeps its own watch list: the explorer's top 50 (seeded by hand) plus any wallet that receives
3
+ // 200 gme or more in one transfer. Every hour it reads every watched balance and counts how many
4
+ // wallets hold more than the stash. Pools and protocol contracts are not wallets: a contract with a
5
+ // protocol name, or an unnamed contract with real code (over 300 bytes), is left out. Smart wallets
6
+ // (7702 delegations, safes, small proxies) count as holders.
7
+ import { parseAbiItem, formatUnits } from 'viem';
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { cfg } from './env.js';
11
+ import { pub } from './chain.js';
12
+ const path = () => join(cfg.siteDir, 'gme-rank.json');
13
+ const PROTOCOL = /pool|manager|locker|hook|initializer|escrow|curve|core|reflections|router|permit|bridge|vault|dividend|factory|quoter|position/i;
14
+ const BIG_TRANSFER = 200; // gme
15
+ const FORGET_UNDER = 100; // gme: a watched wallet under this cannot be above the stash; drop it
16
+ export function readRank() {
17
+ try {
18
+ const st = JSON.parse(readFileSync(path(), 'utf8'));
19
+ return st.rank && st.at ? { rank: st.rank, of: st.of ?? 0, at: st.at } : undefined;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ export async function gmeRank() {
26
+ let st;
27
+ try {
28
+ st = JSON.parse(readFileSync(path(), 'utf8'));
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ if (st.at && Date.now() - new Date(st.at).getTime() < 55 * 60_000)
34
+ return readRank();
35
+ const stashAddr = String(cfg.stash).toLowerCase();
36
+ st.watch[stashAddr] ??= { name: 'the stash' };
37
+ const latest = await pub.getBlockNumber();
38
+ const ev = parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)');
39
+ let from = st.lastBlock ? BigInt(st.lastBlock) + 1n : latest - 40000n, step = 10000n, calls = 0;
40
+ while (from <= latest && calls < 400) {
41
+ const to = from + step - 1n > latest ? latest : from + step - 1n;
42
+ try {
43
+ const logs = await pub.getLogs({ address: cfg.gme, event: ev, fromBlock: from, toBlock: to });
44
+ calls++;
45
+ for (const l of logs) {
46
+ const t = l.args.to.toLowerCase();
47
+ if (Number(formatUnits(l.args.value, 18)) >= BIG_TRANSFER && !st.watch[t])
48
+ st.watch[t] = {};
49
+ }
50
+ from = to + 1n;
51
+ st.lastBlock = to.toString();
52
+ }
53
+ catch (e) {
54
+ calls++;
55
+ if (step > 500n) {
56
+ step /= 2n;
57
+ continue;
58
+ }
59
+ console.log(`[gme-rank] scan stopped at ${st.lastBlock}: ${e.shortMessage || e.message}`);
60
+ break;
61
+ }
62
+ }
63
+ const abi = [parseAbiItem('function balanceOf(address) view returns (uint256)')];
64
+ for (const [a, w] of Object.entries(st.watch)) {
65
+ w.gme = Number(formatUnits(await pub.readContract({ address: cfg.gme, abi, functionName: 'balanceOf', args: [a] }), 18));
66
+ if (w.code == null) {
67
+ const c = await pub.getCode({ address: a });
68
+ w.code = c ? (c.length - 2) / 2 : 0;
69
+ }
70
+ w.out = a !== stashAddr && ((!!w.name && PROTOCOL.test(w.name)) || (!w.name && (w.code ?? 0) > 300));
71
+ }
72
+ const stash = st.watch[stashAddr].gme ?? 0;
73
+ const holders = Object.entries(st.watch).filter(([, w]) => !w.out && (w.gme ?? 0) > 0);
74
+ const above = holders.filter(([a, w]) => a !== stashAddr && (w.gme ?? 0) > stash).sort((x, y) => (y[1].gme ?? 0) - (x[1].gme ?? 0));
75
+ st.rank = above.length + 1;
76
+ st.of = holders.length;
77
+ st.at = new Date().toISOString();
78
+ st.above = above.map(([a, w]) => ({ address: a, gme: Math.round(w.gme ?? 0), name: w.name || undefined }));
79
+ for (const [a, w] of Object.entries(st.watch))
80
+ if (a !== stashAddr && (w.gme ?? 0) < FORGET_UNDER)
81
+ delete st.watch[a];
82
+ writeFileSync(path(), JSON.stringify(st, null, 1));
83
+ console.log(`[gme-rank] the stash (${stash.toFixed(0)} gme) is #${st.rank} among wallets, ${above.length} above it, ${st.of} watched`);
84
+ return readRank();
85
+ }
@@ -0,0 +1,122 @@
1
+ // The holder count, from the chain: a balance map kept from every Transfer event since launch,
2
+ // updated incrementally each run (site/holders-state.json). The explorer blocks automated reads.
3
+ // v3 also remembers, per wallet, when it first received $gmerald and whether it has ever sold any
4
+ // (sent tokens into the pool; a transfer to another wallet is a move, not a sell). That is what "check your cheeks" on the site reads. Wallets that empty out are forgotten,
5
+ // so one that leaves and comes back starts over: "joined" is when the current position began.
6
+ import { parseAbiItem } from 'viem';
7
+ import { readFileSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { cfg } from './env.js';
10
+ import { pub } from './chain.js';
11
+ const LAUNCH_BLOCK = 52845715n;
12
+ const VERSION = 3;
13
+ const ZERO = '0x0000000000000000000000000000000000000000';
14
+ // a sell is a transfer into the pool: v4 settles swaps straight into the PoolManager, and the router and
15
+ // permit2 are the only other places a swap can route through. anything else sent out is a move, not a sell.
16
+ const SINKS = new Set(['0x8366a39cc670b4001a1121b8f6a443a643e40951', '0x8876789976decbfcbbbe364623c63652db8c0904', '0x000000000022d473030f116ddee9f6b43ac78ba3']);
17
+ const statePath = () => join(cfg.siteDir, 'holders-state.json');
18
+ const fresh = () => ({ v: VERSION, lastBlock: (LAUNCH_BLOCK - 1n).toString(), balances: {}, first: {}, out: {} });
19
+ export async function readHolders() {
20
+ let st = fresh();
21
+ try {
22
+ const s = JSON.parse(readFileSync(statePath(), 'utf8'));
23
+ if (s.v === VERSION)
24
+ st = s;
25
+ else
26
+ console.log('[holders] state format changed; rescanning from launch');
27
+ }
28
+ catch { }
29
+ // hourly is plenty for a holder count, and it keeps the state file's commits down
30
+ if (st.scannedAt && st.count && Date.now() - new Date(st.scannedAt).getTime() < 55 * 60_000)
31
+ return st.count;
32
+ const bal = new Map(Object.entries(st.balances).map(([a, v]) => [a, BigInt(v)]));
33
+ const first = st.first ?? {}, out = st.out ?? {};
34
+ const latest = await pub.getBlockNumber();
35
+ let from = BigInt(st.lastBlock) + 1n;
36
+ const ev = parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)');
37
+ let step = 5000n, scanned = 0, calls = 0;
38
+ while (from <= latest && calls < 3000) {
39
+ const to = from + step - 1n > latest ? latest : from + step - 1n;
40
+ try {
41
+ const logs = await pub.getLogs({ address: cfg.token, event: ev, fromBlock: from, toBlock: to });
42
+ calls++;
43
+ // newcomers in this span, and the span's clock: the timestamps of its two ends, blocks between them on a straight line
44
+ const newcomers = new Map();
45
+ for (const l of logs) {
46
+ const t = l.args.to.toLowerCase();
47
+ if (t !== ZERO && !(t in first) && !newcomers.has(t))
48
+ newcomers.set(t, l.blockNumber ?? from);
49
+ }
50
+ let t0 = 0, t1 = 0;
51
+ if (newcomers.size) {
52
+ const [b0, b1] = await Promise.all([pub.getBlock({ blockNumber: from }), pub.getBlock({ blockNumber: to })]);
53
+ calls += 2;
54
+ t0 = Number(b0.timestamp);
55
+ t1 = Number(b1.timestamp);
56
+ }
57
+ // every read for this span succeeded; only now does the state change, so a failed span retries cleanly
58
+ for (const l of logs) {
59
+ const f = l.args.from.toLowerCase(), t = l.args.to.toLowerCase(), v = l.args.value;
60
+ if (f !== ZERO) {
61
+ bal.set(f, (bal.get(f) ?? 0n) - v);
62
+ if (SINKS.has(t))
63
+ out[f] = 1;
64
+ }
65
+ if (t !== ZERO)
66
+ bal.set(t, (bal.get(t) ?? 0n) + v);
67
+ }
68
+ for (const [a, b] of newcomers)
69
+ first[a] = to === from ? t0 : Math.round(t0 + ((t1 - t0) * Number(b - from)) / Number(to - from));
70
+ scanned += logs.length;
71
+ from = to + 1n;
72
+ st.lastBlock = to.toString();
73
+ if (logs.length < 1500 && step < 20000n)
74
+ step *= 2n; // roomy: widen
75
+ }
76
+ catch (e) {
77
+ calls++;
78
+ if (step > 250n) {
79
+ step /= 2n;
80
+ continue;
81
+ } // dense: narrow and retry the same span
82
+ console.log(`[holders] scan stopped at block ${st.lastBlock}: ${e.shortMessage || e.message}`);
83
+ break;
84
+ }
85
+ }
86
+ for (const [a, v] of bal)
87
+ if (v <= 0n)
88
+ bal.delete(a);
89
+ for (const a of Object.keys(first))
90
+ if (!bal.has(a))
91
+ delete first[a];
92
+ for (const a of Object.keys(out))
93
+ if (!bal.has(a))
94
+ delete out[a];
95
+ st.v = VERSION;
96
+ st.balances = Object.fromEntries([...bal].map(([a, v]) => [a, v.toString()]));
97
+ st.first = first;
98
+ st.out = out;
99
+ const n = bal.size;
100
+ st.count = n;
101
+ st.scannedAt = new Date().toISOString();
102
+ writeFileSync(statePath(), JSON.stringify(st));
103
+ console.log(`[holders] ${n} holders · ${scanned} transfers scanned · through block ${st.lastBlock}`);
104
+ return n;
105
+ }
106
+ // The board: wallets holding the seat threshold or more, from the last scan. Infra is not a seat.
107
+ const SEAT = BigInt(cfg.seatThreshold) * 10n ** 18n;
108
+ // not seats: the pool manager, the launch locker, the treasury, the machine, ops
109
+ const NOT_SEATS = new Set(['0x8366a39cc670b4001a1121b8f6a443a643e40951', cfg.locker, cfg.stash, cfg.pressWallet, cfg.ops].filter(Boolean).map((a) => String(a).toLowerCase()));
110
+ export function boardSeats() {
111
+ try {
112
+ const st = JSON.parse(readFileSync(statePath(), 'utf8'));
113
+ let n = 0;
114
+ for (const [a, v] of Object.entries(st.balances))
115
+ if (!NOT_SEATS.has(a) && BigInt(v) >= SEAT)
116
+ n++;
117
+ return n;
118
+ }
119
+ catch {
120
+ return undefined;
121
+ }
122
+ }
package/dist/index.js ADDED
@@ -0,0 +1,56 @@
1
+ const job = process.argv[2] ?? 'press';
2
+ // dry and claimcheck never sign: the flag is read by env.ts before any module can reach wallet()
3
+ if (job === 'dry' || job === 'claimcheck')
4
+ process.env.CLOCKWORK_DRY_RUN = '1';
5
+ if (job === 'init') {
6
+ const { runInit } = await import('./jobs/init.js');
7
+ await runInit();
8
+ process.exit(0);
9
+ }
10
+ if (job === 'claimcheck') {
11
+ const { runClaimCheck } = await import('./jobs/claimcheck.js');
12
+ await runClaimCheck();
13
+ process.exit(0);
14
+ }
15
+ if (job === 'handback') {
16
+ const { runHandback } = await import('./jobs/handback.js');
17
+ await runHandback();
18
+ process.exit(0);
19
+ }
20
+ if (job === 'doctor') {
21
+ const { runDoctor } = await import('./jobs/doctor.js');
22
+ await runDoctor();
23
+ }
24
+ else if (job === 'press' || job === 'dry') {
25
+ const { runPress } = await import('./press.js');
26
+ await runPress();
27
+ }
28
+ else if (job === 'quotecheck') {
29
+ const { runQuoteCheck } = await import('./jobs/quotecheck.js');
30
+ await runQuoteCheck();
31
+ }
32
+ else if (job === 'buys') {
33
+ const { runBuys } = await import('./jobs/buys.js');
34
+ await runBuys();
35
+ }
36
+ else if (job === 'log') {
37
+ const { runLog } = await import('./jobs/log.js');
38
+ await runLog();
39
+ }
40
+ else if (job === 'holders') {
41
+ const { readHolders } = await import('./holders.js');
42
+ console.log(`holders: ${await readHolders()}`);
43
+ }
44
+ else if (job === 'tgcheck') {
45
+ const { runTgCheck } = await import('./jobs/tgcheck.js');
46
+ await runTgCheck();
47
+ }
48
+ else if (job === 'launchcheck') {
49
+ const { runLaunchCheck } = await import('./jobs/launchcheck.js');
50
+ await runLaunchCheck();
51
+ }
52
+ else {
53
+ console.error(`unknown job: ${job} (use init | press | dry | doctor | claimcheck | holders | tgcheck | log | buys | quotecheck | launchcheck | handback <address>)`);
54
+ process.exit(1);
55
+ }
56
+ export {};