From ac2d70596b7d6900f5434c288c84566bfc659fdb Mon Sep 17 00:00:00 2001 From: annacodervibe Date: Sat, 17 Oct 2026 11:13:13 +0100 Subject: [PATCH 1/3] fix(stellar): make the names TTL keeper do real work or fail closed Closes #228 --- stellar/scripts/keeper/README.md | 99 +++--- stellar/scripts/keeper/keeper-core.ts | 233 ++++++++++++++ stellar/scripts/keeper/keeper-rpc.ts | 129 ++++++++ stellar/scripts/keeper/keeper.test.ts | 418 +++++++++++++++----------- stellar/scripts/keeper/keeper.ts | 308 ++++--------------- 5 files changed, 710 insertions(+), 477 deletions(-) create mode 100644 stellar/scripts/keeper/keeper-core.ts create mode 100644 stellar/scripts/keeper/keeper-rpc.ts diff --git a/stellar/scripts/keeper/README.md b/stellar/scripts/keeper/README.md index f68f1b2e..a93c9e09 100644 --- a/stellar/scripts/keeper/README.md +++ b/stellar/scripts/keeper/README.md @@ -8,11 +8,22 @@ Soroban contract storage entries have a TTL (Time-To-Live). Entries that aren't ## How It Works -1. **Enumerate Names**: Queries the Wraith Names contract to find all registered names -2. **Check TTLs**: Reads the remaining TTL for each name entry -3. **Identify At-Risk Names**: Filters names with TTL below the threshold -4. **Extend TTLs**: Calls `extend_name_ttl()` for names needing extension -5. **Monitor Results**: Logs events and statistics +1. **Enumerate names.** The contract keeps no on-chain name list, so the keeper rebuilds it from the contract's `register`, `update` and `release` events (Soroban RPC `getEvents`, starting at `--start-ledger`) and/or from an off-chain index file (`--index-file`, a JSON array of names, e.g. exported from `examples/indexer`). Both sources are merged. +2. **Read TTLs.** For each name it reads the persistent `Name(sha256(name))` ledger entry via `getLedgerEntries` and computes `liveUntilLedgerSeq - latestLedger`. +3. **Extend.** Names with remaining TTL below `--threshold` get a real `extend_name_ttl(name, latest + extend-to)` transaction (simulated, signed, submitted and polled to completion). + +## Fails closed + +The keeper exits with status 1 (and prints `keeper failed [stage]: ...`) when: + +- no name source is configured, or the RPC no longer retains events back to `--start-ledger` (history would be incomplete); +- event enumeration or the latest-ledger read fails; +- any TTL read fails, or a listed name has no live ledger entry (released, expired or archived); +- any `extend_name_ttl` submission fails. + +`--dry-run` performs the same enumeration and TTL reads and fails the same way, but submits nothing. + +Public RPC nodes keep only a few days of events. For a long-lived deployment, either run the keeper often enough, point it at a longer-retention RPC, or pass an index file. ## Permissionless Design @@ -32,7 +43,8 @@ pnpm install ```bash tsx scripts/keeper/keeper.ts extend \ --network testnet \ - --contract CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4 + --contract CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4 \ + --start-ledger 1234567 ``` ### With Custom Thresholds @@ -41,8 +53,9 @@ tsx scripts/keeper/keeper.ts extend \ tsx scripts/keeper/keeper.ts extend \ --network testnet \ --contract CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4 \ - --threshold 1000 \ - --extend-to 500000 + --start-ledger 1234567 \ + --threshold 17280 \ + --extend-to 518400 ``` ### Dry-Run Mode @@ -53,6 +66,8 @@ Preview what would be done without submitting transactions: tsx scripts/keeper/keeper.ts extend \ --network testnet \ --contract CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4 \ + --start-ledger 1234567 \ + --index-file names.json \ --dry-run ``` @@ -70,12 +85,13 @@ tsx scripts/keeper/keeper.ts cost-estimate --names-count 1000 |--------|---------|-------------| | `--network` | testnet | Stellar network (testnet or mainnet) | | `--contract` | - | Wraith Names contract ID (required) | -| `--threshold` | 1000 | TTL threshold in ledgers (~7 minutes) | -| `--extend-to` | 500000 | Target TTL in ledgers (~33 days) | +| `--start-ledger` | - | First ledger to scan events from (deployment ledger) | +| `--index-file` | - | JSON array of names from an off-chain index | +| `--threshold` | 17280 | Extend when remaining TTL is below this (ledgers, ~1 day) | +| `--extend-to` | 518400 | Ledgers from now to extend to (~30 days) | | `--secret-key` | - | Keeper account secret key | | `--dry-run` | false | Show actions without executing | -| `--rpc-url` | - | Custom Soroban RPC URL | -| `--horizon-url` | - | Custom Horizon URL | +| `--rpc-url` | network default | Custom Soroban RPC URL | ## Environment Variables @@ -123,28 +139,12 @@ The Keeper logs: Example output: ``` -šŸ”§ Wraith Names TTL Keeper - Network: testnet - Contract: CAAAA...ABSC4 - TTL Threshold: 1000 ledgers - Extend To: 500000 ledgers - Dry Run: No - -šŸ“Š Current ledger: 123456 - -šŸ“– Enumerating registered names... - Found 42 registered names - -ā±ļø Checking TTLs... - 15/42 names at risk or need extension - -šŸ”„ Extending TTLs... - - alice - - bob - - carol - ... (12 more) - -āœ… Done! +latest ledger 123456 +found 42 registered name(s) +15/42 below threshold 17280 +extended "alice" to ledger 641856 (tx 9f1c...) +... +done: 42 names, 15 below threshold, 15 extended ``` ## Running as a Service @@ -177,7 +177,7 @@ User=keeper WorkingDirectory=/opt/keeper Environment="WRAITH_KEEPER_SECRET=SBXXXXXXXX..." Environment="WRAITH_NAMES_CONTRACT_ID=CAAAAAAA..." -ExecStart=/usr/bin/node /opt/keeper/keeper.ts extend --network testnet +ExecStart=/usr/bin/node /opt/keeper/keeper.ts extend --network testnet --start-ledger StandardOutput=journal StandardError=journal ``` @@ -211,15 +211,14 @@ systemctl status wraith-keeper.timer ```bash # Run every 6 hours -0 */6 * * * /usr/bin/tsx /opt/keeper/scripts/keeper/keeper.ts extend --network testnet >> /var/log/wraith-keeper.log 2>&1 +0 */6 * * * /usr/bin/tsx /opt/keeper/scripts/keeper/keeper.ts extend --network testnet --start-ledger >> /var/log/wraith-keeper.log 2>&1 ``` ## Performance Notes -- **Throughput**: Extend ~100 names per transaction batch +- **Throughput**: one `extend_name_ttl` transaction per name, submitted sequentially - **Cost**: ~0.00002 XLM per name (base fee + Soroban resource fees) - **Frequency**: Recommend running every 6-24 hours -- **Latency**: Typically completes in 5-30 seconds for 1000 names ## Troubleshooting @@ -239,30 +238,28 @@ Verify the contract is deployed and ID is correct: soroban contract info --network testnet --id CAAAA... ``` -### "Names not found" +### "no live ledger entry for N name(s)" + +The name was released, expired or archived. Remove it from the index file, or restore the entry first. + +### "RPC event history starts at ledger ..." -- Verify names are actually registered in the contract -- Check that you're querying the correct network -- Ensure the contract ID is correct +The RPC has pruned events older than `--start-ledger`. Use a longer-retention RPC or pass `--index-file`. ### "extend_name_ttl failed" -- Verify `extend_to_ledger` is in the future -- Check keeper account has enough XLM for fees -- Ensure the name exists in the contract +- Check the keeper account has enough XLM for fees +- Check the contract is not paused ## Testing -Run the keeper tests: - ```bash -# Unit tests (requires vitest) +cd stellar pnpm test scripts/keeper - -# Integration test with local contract -cargo test --workspace --manifest-path stellar/Cargo.toml ``` +The tests use an in-memory fake RPC (no network), plus CLI runs that check the nonzero exit codes. The real Soroban RPC client (`keeper-rpc.ts`) is exercised only by hand against testnet. + ## Cost Model See [KEEPER_COSTS.md](./KEEPER_COSTS.md) for detailed cost analysis. diff --git a/stellar/scripts/keeper/keeper-core.ts b/stellar/scripts/keeper/keeper-core.ts new file mode 100644 index 00000000..8dc8f23c --- /dev/null +++ b/stellar/scripts/keeper/keeper-core.ts @@ -0,0 +1,233 @@ +/** + * Wraith Names TTL keeper - core logic. + * + * Everything that touches the network goes through the `KeeperRpc` interface so + * the flow can be tested deterministically with a fake RPC (see keeper.test.ts). + * + * The keeper fails closed: if names cannot be enumerated, a TTL cannot be read, + * or an extend transaction cannot be submitted, `runKeeper` throws a + * `KeeperError` and the CLI exits nonzero. It never reports success for work it + * did not do. + */ + +import { createHash } from 'crypto'; +import * as fs from 'fs'; + +export class KeeperError extends Error { + constructor( + public readonly stage: 'config' | 'enumeration' | 'ttl-read' | 'submission', + message: string, + ) { + super(message); + this.name = 'KeeperError'; + } +} + +/** A contract event, already decoded from XDR to plain values. */ +export interface DecodedEvent { + ledger: number; + kind: 'register' | 'update' | 'release'; + name: string; +} + +export interface EventPage { + events: DecodedEvent[]; + /** Opaque cursor for the next page, or undefined when exhausted. */ + cursor?: string; + /** Oldest ledger the RPC still serves events for. */ + oldestLedger: number; + latestLedger: number; +} + +export interface KeeperRpc { + getLatestLedger(): Promise; + /** Page through register/update/release events from `startLedger`. */ + getNameEvents(startLedger: number, cursor?: string): Promise; + /** liveUntilLedgerSeq of the persistent Name(hash) entry, or null if no live entry. */ + getNameLiveUntil(nameHash: string): Promise; + /** Submit extend_name_ttl(name, extendTo) and wait for it to land. Returns the tx hash. */ + extendName(name: string, extendTo: number): Promise; +} + +export interface KeeperOptions { + /** Extend names whose remaining TTL (ledgers) is below this. */ + threshold: number; + /** Ledgers from now to extend to. */ + extendTo: number; + dryRun: boolean; + /** First ledger to read events from (e.g. the contract deployment ledger). */ + startLedger?: number; + /** Names from an off-chain index (e.g. the indexer), merged with events. */ + indexedNames?: string[]; +} + +export interface NameStatus { + name: string; + liveUntil: number; + remaining: number; + atRisk: boolean; +} + +export interface KeeperReport { + latestLedger: number; + total: number; + atRisk: number; + extended: { name: string; txHash: string }[]; + dryRun: boolean; +} + +export function nameHash(name: string): string { + return createHash('sha256').update(Buffer.from(name, 'utf8')).digest('hex'); +} + +export function validateOptions(o: KeeperOptions): void { + if (!Number.isInteger(o.threshold) || o.threshold <= 0) { + throw new KeeperError('config', `threshold must be a positive integer, got ${o.threshold}`); + } + if (!Number.isInteger(o.extendTo) || o.extendTo <= o.threshold) { + throw new KeeperError( + 'config', + `extend-to (${o.extendTo}) must be an integer greater than threshold (${o.threshold})`, + ); + } + if (o.startLedger === undefined && !(o.indexedNames && o.indexedNames.length > 0)) { + throw new KeeperError( + 'config', + 'no name source configured: pass --start-ledger (event scan) and/or --index-file', + ); + } +} + +/** + * Build the set of live names. Events are replayed in order (register/update add, + * release removes); indexed names are merged in afterwards. If the event scan was + * requested but the RPC no longer holds events back to `startLedger`, the history + * is incomplete and we refuse to continue rather than silently skip names. + */ +export async function enumerateNames(rpc: KeeperRpc, o: KeeperOptions): Promise { + const names = new Set(); + + if (o.startLedger !== undefined) { + let cursor: string | undefined; + let first = true; + try { + do { + const page = await rpc.getNameEvents(o.startLedger, cursor); + if (first && page.oldestLedger > o.startLedger) { + throw new KeeperError( + 'enumeration', + `RPC event history starts at ledger ${page.oldestLedger}, after --start-ledger ${o.startLedger}; ` + + 'names registered earlier cannot be discovered from events. Supply --index-file or a longer-retention RPC.', + ); + } + first = false; + for (const ev of page.events) { + if (ev.kind === 'release') names.delete(ev.name); + else names.add(ev.name); + } + cursor = page.cursor; + } while (cursor); + } catch (e) { + if (e instanceof KeeperError) throw e; + throw new KeeperError('enumeration', `event enumeration failed: ${(e as Error).message}`); + } + } + + for (const n of o.indexedNames ?? []) names.add(n); + return [...names].sort(); +} + +export async function readStatuses( + rpc: KeeperRpc, + names: string[], + latestLedger: number, + threshold: number, +): Promise { + const out: NameStatus[] = []; + const unreadable: string[] = []; + for (const name of names) { + let liveUntil: number | null; + try { + liveUntil = await rpc.getNameLiveUntil(nameHash(name)); + } catch (e) { + throw new KeeperError('ttl-read', `TTL read failed for "${name}": ${(e as Error).message}`); + } + if (liveUntil === null) { + unreadable.push(name); + continue; + } + const remaining = liveUntil - latestLedger; + out.push({ name, liveUntil, remaining, atRisk: remaining < threshold }); + } + if (unreadable.length > 0) { + throw new KeeperError( + 'ttl-read', + `no live ledger entry for ${unreadable.length} name(s): ${unreadable.join(', ')} ` + + '(released, expired or archived - restore or remove from the index before re-running)', + ); + } + return out; +} + +export async function runKeeper( + rpc: KeeperRpc, + o: KeeperOptions, + log: (msg: string) => void = () => {}, +): Promise { + validateOptions(o); + + let latestLedger: number; + try { + latestLedger = await rpc.getLatestLedger(); + } catch (e) { + throw new KeeperError('enumeration', `could not read latest ledger: ${(e as Error).message}`); + } + log(`latest ledger ${latestLedger}`); + + const names = await enumerateNames(rpc, o); + log(`found ${names.length} registered name(s)`); + + const statuses = await readStatuses(rpc, names, latestLedger, o.threshold); + const risky = statuses.filter((s) => s.atRisk); + log(`${risky.length}/${statuses.length} below threshold ${o.threshold}`); + + const report: KeeperReport = { + latestLedger, + total: statuses.length, + atRisk: risky.length, + extended: [], + dryRun: o.dryRun, + }; + + const target = latestLedger + o.extendTo; + for (const s of risky) { + if (o.dryRun) { + log(`[dry-run] would extend "${s.name}" (remaining ${s.remaining}) to ledger ${target}`); + continue; + } + try { + const txHash = await rpc.extendName(s.name, target); + report.extended.push({ name: s.name, txHash }); + log(`extended "${s.name}" to ledger ${target} (tx ${txHash})`); + } catch (e) { + throw new KeeperError( + 'submission', + `extend_name_ttl failed for "${s.name}" after ${report.extended.length} successful extension(s): ${(e as Error).message}`, + ); + } + } + return report; +} + +export function loadIndexFile(path: string): string[] { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(path, 'utf8')); + } catch (e) { + throw new KeeperError('enumeration', `cannot read index file ${path}: ${(e as Error).message}`); + } + if (!Array.isArray(parsed) || !parsed.every((n) => typeof n === 'string')) { + throw new KeeperError('enumeration', `index file ${path} must be a JSON array of name strings`); + } + return parsed; +} diff --git a/stellar/scripts/keeper/keeper-rpc.ts b/stellar/scripts/keeper/keeper-rpc.ts new file mode 100644 index 00000000..2f601d07 --- /dev/null +++ b/stellar/scripts/keeper/keeper-rpc.ts @@ -0,0 +1,129 @@ +/** + * Soroban RPC implementation of KeeperRpc. + * + * Name enumeration comes from the contract's own `register`, `update` and `release` + * events (the contract keeps no on-chain name list). Remaining TTL is read from the + * persistent `Name(sha256(name))` ledger entry via getLedgerEntries. + */ + +import { + Account, + Address, + BASE_FEE, + Contract, + Keypair, + TransactionBuilder, + nativeToScVal, + rpc, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { DecodedEvent, EventPage, KeeperRpc, nameHash } from './keeper-core'; + +const EVENT_PAGE_LIMIT = 200; + +export class SorobanKeeperRpc implements KeeperRpc { + private server: rpc.Server; + + constructor( + private rpcUrl: string, + private contractId: string, + private passphrase: string, + private keypair?: Keypair, + ) { + this.server = new rpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); + } + + async getLatestLedger(): Promise { + return (await this.server.getLatestLedger()).sequence; + } + + /** Oldest ledger the RPC retains, via raw getHealth (not exposed by SDK v13 types). */ + private async oldestLedger(fallback: number): Promise { + const res = await fetch(this.rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getHealth' }), + }); + const body = (await res.json()) as { result?: { oldestLedger?: number } }; + return body.result?.oldestLedger ?? fallback; + } + + async getNameEvents(startLedger: number, cursor?: string): Promise { + const filters = [{ type: 'contract' as const, contractIds: [this.contractId] }]; + const resp = await this.server.getEvents( + cursor + ? { filters, cursor, limit: EVENT_PAGE_LIMIT } + : { filters, startLedger, limit: EVENT_PAGE_LIMIT }, + ); + const events: DecodedEvent[] = []; + for (const ev of resp.events) { + const kind = scValToNative(ev.topic[0]); + if (kind !== 'register' && kind !== 'update' && kind !== 'release') continue; + const value = scValToNative(ev.value); + // register/update: (name, meta_address); release: name + const name = Array.isArray(value) ? value[0] : value; + if (typeof name !== 'string') { + throw new Error(`unexpected ${kind} event payload at ledger ${ev.ledger}`); + } + events.push({ ledger: ev.ledger, kind, name }); + } + return { + events, + cursor: resp.events.length >= EVENT_PAGE_LIMIT ? resp.cursor : undefined, + oldestLedger: await this.oldestLedger(startLedger), + latestLedger: resp.latestLedger, + }; + } + + private nameKey(hashHex: string): xdr.LedgerKey { + return xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(this.contractId).toScAddress(), + key: xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol('Name'), + xdr.ScVal.scvBytes(Buffer.from(hashHex, 'hex')), + ]), + durability: xdr.ContractDataDurability.persistent(), + }), + ); + } + + async getNameLiveUntil(hashHex: string): Promise { + const res = await this.server.getLedgerEntries(this.nameKey(hashHex)); + const entry = res.entries?.[0]; + if (!entry || entry.liveUntilLedgerSeq === undefined) return null; + return entry.liveUntilLedgerSeq; + } + + async extendName(name: string, extendTo: number): Promise { + if (!this.keypair) throw new Error('no keeper secret key configured'); + const account = await this.server.getAccount(this.keypair.publicKey()); + const op = new Contract(this.contractId).call( + 'extend_name_ttl', + nativeToScVal(name, { type: 'string' }), + nativeToScVal(extendTo, { type: 'u32' }), + ); + const tx = new TransactionBuilder(new Account(account.accountId(), account.sequenceNumber()), { + fee: BASE_FEE, + networkPassphrase: this.passphrase, + }) + .addOperation(op) + .setTimeout(60) + .build(); + + const prepared = await this.server.prepareTransaction(tx); + prepared.sign(this.keypair); + const sent = await this.server.sendTransaction(prepared); + if (sent.status === 'ERROR' || sent.status === 'TRY_AGAIN_LATER') { + throw new Error(`sendTransaction returned ${sent.status}`); + } + const final = await this.server.pollTransaction(sent.hash, { attempts: 30 }); + if (final.status !== rpc.Api.GetTransactionStatus.SUCCESS) { + throw new Error(`transaction ${sent.hash} ended as ${final.status}`); + } + return sent.hash; + } +} + +export { nameHash }; diff --git a/stellar/scripts/keeper/keeper.test.ts b/stellar/scripts/keeper/keeper.test.ts index d99e27a0..af1dfa91 100644 --- a/stellar/scripts/keeper/keeper.test.ts +++ b/stellar/scripts/keeper/keeper.test.ts @@ -1,217 +1,281 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; - -/** - * Keeper Bot Integration Tests - * - * These tests verify that the keeper bot correctly: - * 1. Connects to the Stellar network - * 2. Enumerates registered names - * 3. Checks TTLs - * 4. Extends names that need extension - * 5. Handles idempotency correctly - */ - -describe('Wraith Names TTL Keeper Bot', () => { - describe('Configuration', () => { - it('should validate network parameter', () => { - // Keeper should accept testnet and mainnet - expect(['testnet', 'mainnet']).toContain('testnet'); - }); - - it('should require contract ID', () => { - // Keeper should require a valid contract ID - const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; - expect(contractId).toBeDefined(); - expect(contractId.startsWith('C')).toBe(true); - }); - - it('should support dry-run mode', () => { - // Keeper should have a --dry-run flag - const dryRun = true; - expect(dryRun).toBe(true); - }); - - it('should support configurable threshold', () => { - const threshold = 1000; - expect(threshold).toBeGreaterThan(0); - }); - - it('should support configurable extend-to ledger', () => { - const extendTo = 500_000; - expect(extendTo).toBeGreaterThan(0); +import { describe, it, expect, vi } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + DecodedEvent, + EventPage, + KeeperError, + KeeperRpc, + loadIndexFile, + nameHash, + runKeeper, +} from './keeper-core'; + +const LATEST = 1_000_000; + +/** Deterministic in-memory RPC. Entries map name -> liveUntil (absent = no live entry). */ +function fakeRpc(opts: { + pages?: EventPage[]; + liveUntil?: Record; + oldestLedger?: number; + failTtlFor?: string; + failExtendFor?: string; + failEvents?: boolean; + failLatest?: boolean; +}) { + const extendCalls: { name: string; to: number }[] = []; + const ttlByHash = new Map( + Object.entries(opts.liveUntil ?? {}).map(([n, v]) => [nameHash(n), v] as const), + ); + const pages = opts.pages ?? []; + let pageIdx = 0; + const rpc: KeeperRpc = { + getLatestLedger: async () => { + if (opts.failLatest) throw new Error('rpc down'); + return LATEST; + }, + getNameEvents: async () => { + if (opts.failEvents) throw new Error('getEvents unavailable'); + const p = pages[pageIdx++] ?? { events: [], oldestLedger: 0, latestLedger: LATEST }; + return { ...p, oldestLedger: opts.oldestLedger ?? p.oldestLedger }; + }, + getNameLiveUntil: async (h) => { + if (opts.failTtlFor && nameHash(opts.failTtlFor) === h) throw new Error('getLedgerEntries 503'); + return ttlByHash.has(h) ? (ttlByHash.get(h) as number) : null; + }, + extendName: async (name, to) => { + if (opts.failExtendFor === name) throw new Error('tx failed'); + extendCalls.push({ name, to }); + return `tx-${name}`; + }, + }; + return { rpc, extendCalls }; +} + +const ev = (kind: DecodedEvent['kind'], name: string): DecodedEvent => ({ ledger: 10, kind, name }); +const page = (events: DecodedEvent[], cursor?: string): EventPage => ({ + events, + cursor, + oldestLedger: 0, + latestLedger: LATEST, +}); +const base = { threshold: 1000, extendTo: 500_000, dryRun: false, startLedger: 1 }; + +describe('enumeration', () => { + it('replays register/update/release events across pages', async () => { + const { rpc } = fakeRpc({ + pages: [ + page([ev('register', 'alice'), ev('register', 'bob')], 'c1'), + page([ev('release', 'bob'), ev('update', 'alice'), ev('register', 'carol')]), + ], + liveUntil: { alice: LATEST + 10, carol: LATEST + 10 }, }); + const report = await runKeeper(rpc, { ...base, dryRun: true }); + expect(report.total).toBe(2); }); - describe('Operations', () => { - it('should get current ledger', () => { - // In real test, would call actual Horizon API - const currentLedger = 123456; - expect(currentLedger).toBeGreaterThan(0); - }); - - it('should enumerate registered names', () => { - // Would query contract storage - const names = ['alice', 'bob', 'carol']; - expect(names).toHaveLength(3); - }); - - it('should check TTL for each name', () => { - // Would query contract TTL state - const ttl = 10_000; - const threshold = 1000; - expect(ttl).toBeGreaterThan(threshold); + it('merges an index file with events and de-duplicates', async () => { + const { rpc } = fakeRpc({ + pages: [page([ev('register', 'alice')])], + liveUntil: { alice: LATEST + 10, dave: LATEST + 10 }, }); + const report = await runKeeper(rpc, { ...base, dryRun: true, indexedNames: ['alice', 'dave'] }); + expect(report.total).toBe(2); + }); - it('should filter names needing extension', () => { - const names = [ - { name: 'alice', ttl: 500 }, - { name: 'bob', ttl: 2000 }, - { name: 'carol', ttl: 100 }, - ]; - const threshold = 1000; - - const needsExtension = names.filter((n) => n.ttl < threshold); - expect(needsExtension).toHaveLength(2); - expect(needsExtension.map((n) => n.name)).toEqual(['alice', 'carol']); - }); - - it('should extend names idempotently', () => { - // First call - const result1 = { success: true, namesExtended: 5 }; - // Second call in same ledger - const result2 = { success: true, namesExtended: 0 }; - - expect(result1.namesExtended).toBeGreaterThan(0); - expect(result2.namesExtended).toEqual(0); // Idempotent in same ledger - }); - - it('should batch extend operations', () => { - const namesPerBatch = 10; - const totalNames = 50; - const expectedBatches = Math.ceil(totalNames / namesPerBatch); - - expect(expectedBatches).toEqual(5); + it('works from an index file alone', async () => { + const { rpc } = fakeRpc({ liveUntil: { dave: LATEST + 10 } }); + const report = await runKeeper(rpc, { + threshold: 1000, + extendTo: 500_000, + dryRun: true, + indexedNames: ['dave'], }); + expect(report.total).toBe(1); }); - describe('Error Handling', () => { - it('should handle network errors gracefully', () => { - const networkError = new Error('Connection failed'); - expect(() => { - throw networkError; - }).toThrow('Connection failed'); + it('fails closed when event history is truncated before --start-ledger', async () => { + const { rpc } = fakeRpc({ pages: [page([ev('register', 'alice')])], oldestLedger: 500 }); + await expect(runKeeper(rpc, { ...base, startLedger: 100 })).rejects.toMatchObject({ + stage: 'enumeration', }); + }); - it('should handle non-existent names gracefully', () => { - const names = ['alice', 'bob']; - const nameToExtend = 'ghost'; + it('fails closed when the events RPC is unavailable', async () => { + const { rpc, extendCalls } = fakeRpc({ failEvents: true }); + await expect(runKeeper(rpc, base)).rejects.toMatchObject({ stage: 'enumeration' }); + expect(extendCalls).toHaveLength(0); + }); - expect(names.includes(nameToExtend)).toBe(false); - }); + it('fails closed when the latest ledger cannot be read', async () => { + const { rpc } = fakeRpc({ failLatest: true }); + await expect(runKeeper(rpc, base)).rejects.toMatchObject({ stage: 'enumeration' }); + }); - it('should handle invalid extend-to ledger', () => { - const currentLedger = 100_000; - const invalidExtendTo = 50_000; + it('refuses to run with no name source configured', async () => { + const { rpc } = fakeRpc({}); + await expect( + runKeeper(rpc, { threshold: 1000, extendTo: 500_000, dryRun: true }), + ).rejects.toMatchObject({ stage: 'config' }); + }); +}); - expect(invalidExtendTo).toBeLessThan(currentLedger); +describe('ttl reads and extension', () => { + it('extends only names below the threshold, to latest + extendTo', async () => { + const { rpc, extendCalls } = fakeRpc({ + pages: [page([ev('register', 'alice'), ev('register', 'bob'), ev('register', 'carol')])], + liveUntil: { alice: LATEST + 500, bob: LATEST + 5000, carol: LATEST + 999 }, }); + const report = await runKeeper(rpc, base); + expect(extendCalls).toEqual([ + { name: 'alice', to: LATEST + 500_000 }, + { name: 'carol', to: LATEST + 500_000 }, + ]); + expect(report.atRisk).toBe(2); + expect(report.extended.map((e) => e.txHash)).toEqual(['tx-alice', 'tx-carol']); + }); - it('should validate secret key format', () => { - const validSecret = 'SBXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; - const invalidSecret = 'invalid'; - - expect(validSecret.length).toBeGreaterThan(10); - expect(invalidSecret).toHaveLength(7); + it('treats remaining == threshold as healthy', async () => { + const { rpc, extendCalls } = fakeRpc({ + pages: [page([ev('register', 'alice')])], + liveUntil: { alice: LATEST + 1000 }, }); + await runKeeper(rpc, base); + expect(extendCalls).toHaveLength(0); }); - describe('Cost Estimation', () => { - it('should calculate per-name cost', () => { - const baseFeeXLM = 0.00001; - const resourceFeeXLM = 0.00001; - const costPerName = baseFeeXLM + resourceFeeXLM; - - expect(costPerName).toBeCloseTo(0.00002, 8); + it('does nothing and succeeds when every name is healthy', async () => { + const { rpc, extendCalls } = fakeRpc({ + pages: [page([ev('register', 'alice')])], + liveUntil: { alice: LATEST + 10_000 }, }); + const report = await runKeeper(rpc, base); + expect(report.atRisk).toBe(0); + expect(extendCalls).toHaveLength(0); + }); - it('should estimate annual cost for 1000 names', () => { - const namesCount = 1000; - const costPerName = 0.00002; - const extensionsPerYear = 10; - - const annualCost = namesCount * costPerName * extensionsPerYear; - expect(annualCost).toBeCloseTo(0.2, 1); + it('fails when a TTL read errors', async () => { + const { rpc, extendCalls } = fakeRpc({ + pages: [page([ev('register', 'alice'), ev('register', 'bob')])], + liveUntil: { alice: LATEST + 1, bob: LATEST + 1 }, + failTtlFor: 'bob', }); + await expect(runKeeper(rpc, base)).rejects.toMatchObject({ stage: 'ttl-read' }); + expect(extendCalls).toHaveLength(0); // nothing submitted on partial knowledge + }); - it('should scale cost estimate linearly', () => { - const costPerName = 0.00002; - const cost1000 = 1000 * costPerName; - const cost10000 = 10000 * costPerName; - - expect(cost10000).toEqual(cost1000 * 10); + it('fails when a name has no live ledger entry', async () => { + const { rpc } = fakeRpc({ + pages: [page([ev('register', 'alice')])], + liveUntil: {}, }); + await expect(runKeeper(rpc, base)).rejects.toMatchObject({ stage: 'ttl-read' }); }); - describe('Observability', () => { - it('should log operation start', () => { - const log = 'Starting keeper service...'; - expect(log).toContain('keeper'); + it('fails with a submission error and reports prior progress', async () => { + const { rpc, extendCalls } = fakeRpc({ + pages: [page([ev('register', 'alice'), ev('register', 'bob')])], + liveUntil: { alice: LATEST + 1, bob: LATEST + 1 }, + failExtendFor: 'bob', }); + const err = await runKeeper(rpc, base).catch((e) => e); + expect(err).toBeInstanceOf(KeeperError); + expect(err.stage).toBe('submission'); + expect(err.message).toContain('after 1 successful'); + expect(extendCalls.map((c) => c.name)).toEqual(['alice']); + }); +}); - it('should log number of names found', () => { - const namesCount = 42; - const log = `Found ${namesCount} registered names`; - - expect(log).toContain('42'); - expect(log).toContain('registered'); +describe('dry run', () => { + it('reads TTLs and logs, but submits nothing', async () => { + const { rpc, extendCalls } = fakeRpc({ + pages: [page([ev('register', 'alice')])], + liveUntil: { alice: LATEST + 5 }, }); + const log = vi.fn(); + const report = await runKeeper(rpc, { ...base, dryRun: true }, log); + expect(extendCalls).toHaveLength(0); + expect(report.atRisk).toBe(1); + expect(report.extended).toHaveLength(0); + expect(log.mock.calls.some((c) => String(c[0]).includes('[dry-run] would extend "alice"'))).toBe(true); + }); - it('should log number of names extended', () => { - const namesExtended = 15; - const log = `Extended ${namesExtended} names`; - - expect(log).toContain('15'); - }); + it('still fails closed on unreadable TTLs', async () => { + const { rpc } = fakeRpc({ pages: [page([ev('register', 'alice')])], liveUntil: {} }); + await expect(runKeeper(rpc, { ...base, dryRun: true })).rejects.toMatchObject({ stage: 'ttl-read' }); + }); +}); - it('should log events for each extended name', () => { - const names = ['alice', 'bob', 'carol']; - names.forEach((name) => { - const log = `Extending "${name}"`; - expect(log).toContain(name); - }); - }); +describe('option validation', () => { + it.each([ + [{ ...base, threshold: 0 }], + [{ ...base, threshold: Number.NaN }], + [{ ...base, extendTo: 1000 }], + [{ ...base, extendTo: 10 }], + ])('rejects %j', async (opts) => { + const { rpc } = fakeRpc({}); + await expect(runKeeper(rpc, opts)).rejects.toMatchObject({ stage: 'config' }); }); +}); - describe('Idempotency', () => { - it('should be safe to call multiple times', () => { - let callCount = 0; - const mockExtend = () => { - callCount++; - return { success: true }; - }; +describe('index file', () => { + const tmp = (content: string) => { + const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'keeper-')), 'names.json'); + fs.writeFileSync(p, content); + return p; + }; + it('loads a JSON array of names', () => { + expect(loadIndexFile(tmp('["alice","bob"]'))).toEqual(['alice', 'bob']); + }); + it('rejects malformed content', () => { + expect(() => loadIndexFile(tmp('{"a":1}'))).toThrow(KeeperError); + expect(() => loadIndexFile(tmp('not json'))).toThrow(KeeperError); + expect(() => loadIndexFile('/nonexistent/names.json')).toThrow(KeeperError); + }); +}); - mockExtend(); - mockExtend(); - mockExtend(); +describe('nameHash', () => { + it('is sha256 of the utf8 name (matches contract hash_name)', () => { + expect(nameHash('alice')).toBe( + '2bd806c97f0e00af1a1fc3328fa763a9269723c8db8fac4f93af71db186d6e90', + ); + }); +}); - expect(callCount).toEqual(3); +describe('cli exit codes', () => { + const cli = path.resolve(__dirname, 'keeper.ts'); + const run = (args: string[], env: Record = {}) => + spawnSync('npx', ['tsx', cli, 'extend', ...args], { + encoding: 'utf8', + env: { PATH: process.env.PATH as string, HOME: process.env.HOME as string, ...env }, + timeout: 60_000, }); - it('should not double-extend in same ledger', () => { - const ledger = 100_000; - const namesExtended = new Set(); - - // First call - namesExtended.add('alice'); - namesExtended.add('bob'); + it('exits nonzero without a contract id', () => { + const r = run(['--dry-run', '--start-ledger', '1']); + expect(r.status).toBe(1); + expect(r.stderr).toContain('[config]'); + }); - // Second call in same ledger - const secondCallNames = new Set(); - secondCallNames.add('alice'); // Already extended - secondCallNames.add('bob'); // Already extended + it('exits nonzero when the RPC is unreachable (dry run, no network needed)', () => { + const r = run([ + '--contract', 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + '--rpc-url', 'http://127.0.0.1:1', + '--start-ledger', '1', + '--dry-run', + ]); + expect(r.status).toBe(1); + expect(r.stderr).toContain('keeper failed'); + }); - // Idempotency means no additional work - expect(secondCallNames.size).toEqual(namesExtended.size); - }); + it('exits nonzero when not dry-run and no secret key is set', () => { + const r = run([ + '--contract', 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + '--start-ledger', '1', + ]); + expect(r.status).toBe(1); + expect(r.stderr).toContain('[config]'); }); }); diff --git a/stellar/scripts/keeper/keeper.ts b/stellar/scripts/keeper/keeper.ts index 4b511d7e..61148ba5 100644 --- a/stellar/scripts/keeper/keeper.ts +++ b/stellar/scripts/keeper/keeper.ts @@ -1,270 +1,80 @@ #!/usr/bin/env tsx /** - * Wraith Names TTL Keeper Service + * Wraith Names TTL Keeper * - * Permissionless keeper bot that extends the TTL of registered names - * that are at risk of being archived. + * Permissionless keeper that finds registered names, reads their remaining TTL + * from Soroban RPC and submits real `extend_name_ttl` transactions for names + * below the threshold. Exits nonzero if names cannot be enumerated, a TTL cannot + * be read, or a submission fails. * * Usage: - * tsx keeper.ts --help - * tsx keeper.ts --network testnet --contract - * tsx keeper.ts --network testnet --contract --dry-run - * tsx keeper.ts --network testnet --contract --threshold 1000 --extend-to 500000 + * tsx keeper.ts extend --network testnet --contract --start-ledger [--index-file names.json] [--dry-run] */ import { Command } from 'commander'; -import { - Keypair, - FeeBumpTransaction, - TransactionBuilder, - Networks, - Operation, - BASE_FEE, - Server, - Account, - nativeToScval, - scvalToNative, - xdr, -} from '@stellar/stellar-sdk'; -import * as fs from 'fs'; +import { Keypair, Networks } from '@stellar/stellar-sdk'; +import { KeeperError, loadIndexFile, runKeeper } from './keeper-core'; +import { SorobanKeeperRpc } from './keeper-rpc'; -const DEFAULT_THRESHOLD = 1000; // ~7 minutes -const DEFAULT_EXTEND_TO = 500_000; // ~33 days +const DEFAULT_THRESHOLD = 17_280; // ~1 day, matches TTL_THRESHOLD in the contract +const DEFAULT_EXTEND_TO = 518_400; // ~30 days, matches TTL_EXTEND_TO in the contract -interface KeeperConfig { - network: 'testnet' | 'mainnet'; - contractId: string; - threshold: number; - extendTo: number; - dryRun: boolean; - secretKey?: string; - rpcUrl?: string; - horizonUrl?: string; -} - -interface NameEntry { - name: string; - stealthMetaAddress: string; - owner: string; - remainingTtl?: number; - needsExtension?: boolean; -} - -class NamesTTLKeeper { - config: KeeperConfig; - keypair: Keypair; - server: Server; - rpcServer: string; - - constructor(config: KeeperConfig) { - this.config = config; - - // Determine network parameters - const networkConfig = this.getNetworkConfig(config.network); - this.rpcServer = config.rpcUrl || networkConfig.rpcUrl; - const horizonUrl = config.horizonUrl || networkConfig.horizonUrl; - - // Initialize Stellar SDK - this.server = new Server(horizonUrl); - - // Load or generate keypair - if (config.secretKey) { - this.keypair = Keypair.fromSecret(config.secretKey); - } else { - // Try to load from environment or default - const envKey = process.env.WRAITH_KEEPER_SECRET; - if (envKey) { - this.keypair = Keypair.fromSecret(envKey); - } else { - throw new Error( - 'No secret key provided. Set WRAITH_KEEPER_SECRET environment variable or use --secret-key', - ); - } - } - } - - private getNetworkConfig(network: 'testnet' | 'mainnet') { - if (network === 'testnet') { - return { - networkId: Networks.TESTNET_NETWORK_PASSPHRASE, - horizonUrl: 'https://horizon-testnet.stellar.org', - rpcUrl: 'https://soroban-testnet.stellar.org', - }; - } else { - return { - networkId: Networks.PUBLIC_NETWORK_PASSPHRASE, - horizonUrl: 'https://horizon.stellar.org', - rpcUrl: 'https://soroban-mainnet.stellar.org', - }; - } - } - - async run(): Promise { - console.log(`\nšŸ”§ Wraith Names TTL Keeper`); - console.log(` Network: ${this.config.network}`); - console.log(` Contract: ${this.config.contractId}`); - console.log(` TTL Threshold: ${this.config.threshold} ledgers`); - console.log(` Extend To: ${this.config.extendTo} ledgers`); - console.log(` Dry Run: ${this.config.dryRun ? 'Yes' : 'No'}\n`); - - try { - // Step 1: Get current ledger - const currentLedger = await this.getCurrentLedger(); - console.log(`šŸ“Š Current ledger: ${currentLedger}`); - - // Step 2: Enumerate all registered names - console.log(`\nšŸ“– Enumerating registered names...`); - const names = await this.enumerateNames(); - console.log(` Found ${names.length} registered names`); - - if (names.length === 0) { - console.log(`\nāœ… No names to extend. Done!`); - return; - } - - // Step 3: Check TTL for each name (requires contract read) - console.log(`\nā±ļø Checking TTLs...`); - const atRisk = names.filter((name) => { - // This is a placeholder - in production, you'd query the contract's internal TTL state - // For now, we assume all names might need extension - return true; - }); - - console.log(` ${atRisk.length}/${names.length} names at risk or need extension`); - - if (atRisk.length === 0) { - console.log(`\nāœ… All names have sufficient TTL. Done!`); - return; - } - - // Step 4: Extend TTLs - console.log(`\nšŸ”„ Extending TTLs...`); - if (this.config.dryRun) { - console.log(` [DRY RUN] Would extend ${atRisk.length} names`); - atRisk.forEach((name) => { - console.log(` - ${name.name}`); - }); - } else { - await this.extendNames(atRisk, currentLedger); - } - - console.log(`\nāœ… Done!`); - } catch (error) { - console.error(`\nāŒ Error:`, error); - process.exit(1); - } - } - - private async getCurrentLedger(): Promise { - try { - // Get latest ledger from Horizon - const ledgers = await this.server.ledgers().limit(1).order('desc').call(); - return ledgers.records[0].sequence; - } catch (error) { - console.error('Failed to get current ledger:', error); - throw error; - } - } - - private async enumerateNames(): Promise { - // In a real implementation, you would: - // 1. Query the contract's storage to get all name hashes - // 2. Or maintain an off-chain index of registered names - // 3. Or use Soroban RPC to query contract state - - // For now, this is a placeholder that would be implemented - // with actual contract querying via Soroban RPC or a subgraph - - console.log(` Note: Name enumeration requires contract state query`); - console.log(` In production, use Soroban RPC or a subgraph indexer`); - - return []; - } - - private async extendNames(names: NameEntry[], currentLedger: number): Promise { - // In a real implementation, you would: - // 1. Batch extend operations for efficiency - // 2. Build transactions for the contract's extend_name_ttl function - // 3. Submit transactions to the network - - console.log(` Submitting ${names.length} extend operations...`); - - for (const name of names) { - console.log(` - Extending "${name.name}" to ledger ${this.config.extendTo}`); - } - - console.log(` All extends submitted!`); - } -} +const NETWORKS = { + testnet: { passphrase: Networks.TESTNET, rpcUrl: 'https://soroban-testnet.stellar.org' }, + mainnet: { passphrase: Networks.PUBLIC, rpcUrl: 'https://mainnet.sorobanrpc.com' }, +} as const; async function main() { const program = new Command(); - - program - .name('wraith-names-keeper') - .description('TTL Keeper service for Wraith Names contract') - .version('1.0.0'); + program.name('wraith-names-keeper').description('TTL keeper for the Wraith Names contract'); program .command('extend') - .description('Run the keeper service to extend name TTLs') - .option( - '--network ', - 'Stellar network (testnet or mainnet)', - 'testnet', - ) - .option( - '--contract ', - 'Wraith Names contract ID', - process.env.WRAITH_NAMES_CONTRACT_ID, - ) - .option( - '--threshold ', - 'TTL threshold in ledgers (extend if below this)', - String(DEFAULT_THRESHOLD), - ) - .option( - '--extend-to ', - 'Target TTL in ledgers', - String(DEFAULT_EXTEND_TO), - ) - .option( - '--secret-key ', - 'Keeper account secret key (or set WRAITH_KEEPER_SECRET)', - process.env.WRAITH_KEEPER_SECRET, - ) - .option( - '--rpc-url ', - 'Soroban RPC URL', - ) - .option( - '--horizon-url ', - 'Horizon URL', - ) - .option( - '--dry-run', - 'Show what would be done without submitting transactions', - false, - ) - .action(async (options) => { - if (!options.contract) { - console.error('Error: --contract or WRAITH_NAMES_CONTRACT_ID required'); + .description('Extend the TTL of registered names that are close to expiry') + .option('--network ', 'testnet or mainnet', 'testnet') + .option('--contract ', 'Wraith Names contract ID', process.env.WRAITH_NAMES_CONTRACT_ID) + .option('--start-ledger ', 'first ledger to scan events from (contract deployment ledger)') + .option('--index-file ', 'JSON array of names from an off-chain index (merged with events)') + .option('--threshold ', 'extend when remaining TTL is below this', String(DEFAULT_THRESHOLD)) + .option('--extend-to ', 'ledgers from now to extend to', String(DEFAULT_EXTEND_TO)) + .option('--secret-key ', 'keeper secret key (or WRAITH_KEEPER_SECRET)', process.env.WRAITH_KEEPER_SECRET) + .option('--rpc-url ', 'Soroban RPC URL') + .option('--dry-run', 'read TTLs and report, but submit nothing', false) + .action(async (o) => { + try { + if (!o.contract) throw new KeeperError('config', '--contract or WRAITH_NAMES_CONTRACT_ID required'); + if (o.network !== 'testnet' && o.network !== 'mainnet') { + throw new KeeperError('config', `unknown network ${o.network}`); + } + if (!o.dryRun && !o.secretKey) { + throw new KeeperError('config', 'set WRAITH_KEEPER_SECRET or --secret-key (not needed with --dry-run)'); + } + const net = NETWORKS[o.network as 'testnet' | 'mainnet']; + const rpc = new SorobanKeeperRpc( + o.rpcUrl ?? net.rpcUrl, + o.contract, + net.passphrase, + o.secretKey ? Keypair.fromSecret(o.secretKey) : undefined, + ); + const report = await runKeeper( + rpc, + { + threshold: parseInt(o.threshold, 10), + extendTo: parseInt(o.extendTo, 10), + dryRun: o.dryRun, + startLedger: o.startLedger !== undefined ? parseInt(o.startLedger, 10) : undefined, + indexedNames: o.indexFile ? loadIndexFile(o.indexFile) : undefined, + }, + (m) => console.log(m), + ); + console.log( + `done: ${report.total} names, ${report.atRisk} below threshold, ${report.extended.length} extended${report.dryRun ? ' (dry run)' : ''}`, + ); + } catch (e) { + const stage = e instanceof KeeperError ? e.stage : 'unexpected'; + console.error(`keeper failed [${stage}]: ${(e as Error).message}`); process.exit(1); } - - const config: KeeperConfig = { - network: options.network, - contractId: options.contract, - threshold: parseInt(options.threshold, 10), - extendTo: parseInt(options.extendTo, 10), - dryRun: options.dryRun, - secretKey: options.secretKey, - rpcUrl: options.rpcUrl, - horizonUrl: options.horizonUrl, - }; - - const keeper = new NamesTTLKeeper(config); - await keeper.run(); }); program From 58f36b7a688a5e28ab1b2365f87b2cbd5e6bcfa6 Mon Sep 17 00:00:00 2001 From: annacodervibe Date: Mon, 19 Oct 2026 01:17:11 +0100 Subject: [PATCH 2/3] fix(stellar): treat extend_name_ttl and bulk_renew extend_to as a relative TTL --- AUTHORIZATION_MATRIX.md | 2 +- stellar/ERRORS.md | 2 +- stellar/abi/wraith_names.json | 8 +- stellar/bench/src/lib.rs | 4 +- .../tests/storage_rent_ttl.rs | 82 +++++++++++++------ stellar/scripts/keeper/README.md | 8 +- stellar/scripts/keeper/keeper-core.ts | 19 +++-- stellar/scripts/keeper/keeper.test.ts | 25 +++++- stellar/wraith-names/src/lib.rs | 64 +++++++-------- stellar/wraith-names/tests/extend_name_ttl.rs | 36 +++----- stellar/wraith-names/tests/metrics.rs | 4 +- stellar/wraith-names/tests/names_bulk.rs | 13 ++- 12 files changed, 154 insertions(+), 113 deletions(-) diff --git a/AUTHORIZATION_MATRIX.md b/AUTHORIZATION_MATRIX.md index 51282189..a1e1b57f 100644 --- a/AUTHORIZATION_MATRIX.md +++ b/AUTHORIZATION_MATRIX.md @@ -490,7 +490,7 @@ It covers: | 16 | ThresholdNotMet | Guardian approvals < threshold | | 17 | TooManyGuardians | >max guardians | | 18 | InvalidThreshold | Guardian threshold invalid | -| 19 | InvalidExtendLedger | `extend_to ≤ current_ledger` | +| 19 | InvalidExtendLedger | `extend_to == 0` (TTL in ledgers from now) | | 20 | ParentNotFound | Subdomain parent missing | | 32 | Paused | Contract paused | | 21 | MultisigNotInitialized | `init_multisig` not called | diff --git a/stellar/ERRORS.md b/stellar/ERRORS.md index 29f70bb6..5d28d3e3 100644 --- a/stellar/ERRORS.md +++ b/stellar/ERRORS.md @@ -109,7 +109,7 @@ Code Name Meaning Introduced in 16 NamesError::ThresholdNotMet Recovery guardian threshold has not been met. pre-catalog 17 NamesError::TooManyGuardians Guardian set exceeds the supported size. pre-catalog 18 NamesError::InvalidThreshold Guardian or multisig threshold is invalid. pre-catalog -19 NamesError::InvalidExtendLedger Requested TTL extension ledger is not in the future. pre-catalog +19 NamesError::InvalidExtendLedger Requested TTL extension (`extend_to`, in ledgers from now) is zero. pre-catalog 20 NamesError::ParentNotFound Subdomain parent name does not exist. pre-catalog 21 NamesError::MultisigNotInitialized Protocol governance multisig has not been initialized. pre-catalog 22 NamesError::MultisigAlreadyInitialized Protocol governance multisig initialization was attempted more than once. pre-catalog diff --git a/stellar/abi/wraith_names.json b/stellar/abi/wraith_names.json index a999e087..13541ea4 100644 --- a/stellar/abi/wraith_names.json +++ b/stellar/abi/wraith_names.json @@ -394,7 +394,7 @@ }, { "function_v0": { - "doc": "Renew (extend TTL for) multiple names in a single atomic transaction.\\n\\nAll names must exist. If any name is not found, the entire operation\\nreverts.", + "doc": "Renew (extend TTL for) multiple names in a single atomic transaction.\\n\\n`extend_to` is a TTL in ledgers counted from the current ledger, not a\\nledger sequence. Each name ends up live for at least `extend_to` more\\nledgers (capped by the network's max entry TTL).\\n\\nAll names must exist. If any name is not found, the entire operation\\nreverts.", "name": "bulk_renew", "inputs": [ { @@ -408,7 +408,7 @@ }, { "doc": "", - "name": "extend_to_ledger", + "name": "extend_to", "type_": "u32" } ], @@ -1056,7 +1056,7 @@ }, { "function_v0": { - "doc": "Extend TTL for persistent storage entries only.\\nExtend the TTL of a registered name to a future ledger.\\nThis is a permissionless function that anyone can call.\\nIdempotent: calling twice in the same ledger has no additional effect.", + "doc": "Extend the TTL of a registered name (its entry, reverse entry and the\\ncontract instance) so it stays live for at least `extend_to` more ledgers.\\n\\n`extend_to` is a TTL in ledgers counted from the current ledger, not a\\nledger sequence, and is capped by the network's max entry TTL. Entries\\nthat already have at least that much TTL left are left untouched.\\nThis is a permissionless function that anyone can call.", "name": "extend_name_ttl", "inputs": [ { @@ -1066,7 +1066,7 @@ }, { "doc": "", - "name": "extend_to_ledger", + "name": "extend_to", "type_": "u32" } ], diff --git a/stellar/bench/src/lib.rs b/stellar/bench/src/lib.rs index 44de12c7..4ed0b65d 100644 --- a/stellar/bench/src/lib.rs +++ b/stellar/bench/src/lib.rs @@ -247,7 +247,7 @@ pub fn collect_rows() -> std::vec::Vec { for i in 0..count { client.register(&owner, &names.get(i).unwrap(), &metas.get(i).unwrap()); } - client.bulk_renew(&names, &(env.ledger().sequence() + 10_000)); + client.bulk_renew(&names, &10_000); }, )); } @@ -262,7 +262,7 @@ pub fn collect_rows() -> std::vec::Vec { let client = WraithNamesContractClient::new(env, &contract_id); let n = SorobanString::from_str(env, "alice"); client.register(&Address::generate(env), &n, &bytes(env, 64, 9)); - client.extend_name_ttl(&n, &(env.ledger().sequence() + 10_000)); + client.extend_name_ttl(&n, &10_000); }, )); diff --git a/stellar/integration-tests/tests/storage_rent_ttl.rs b/stellar/integration-tests/tests/storage_rent_ttl.rs index 02e3c8d3..a5f1f90a 100644 --- a/stellar/integration-tests/tests/storage_rent_ttl.rs +++ b/stellar/integration-tests/tests/storage_rent_ttl.rs @@ -322,24 +322,52 @@ fn names_entries_live_on_last_ledger() { fn names_extend_name_ttl_before_expiry_outlives_original_expiry() { let n = Names::new(); advance(&n.env, TTL_EXTEND_TO - 1); - let extend_to = n.seq() + 1; - n.client.extend_name_ttl(&n.name, &extend_to); - assert!(n.ttl(&n.name_key) >= TTL_EXTEND_TO); - assert!(n.ttl(&n.reverse_key) >= TTL_EXTEND_TO); + n.client.extend_name_ttl(&n.name, &TTL_EXTEND_TO); + assert_eq!(n.ttl(&n.name_key), TTL_EXTEND_TO); + assert_eq!(n.ttl(&n.reverse_key), TTL_EXTEND_TO); advance(&n.env, TTL_EXTEND_TO - 1); assert_eq!(n.client.resolve(&n.name), n.meta); assert_eq!(n.client.name_of(&n.meta), n.name); } +/// `extend_to` is a TTL counted from the current ledger. The resulting +/// `live_until` must be `current ledger + extend_to`, not `extend_to` added to +/// a value that already contains the current ledger (the keeper once passed an +/// absolute ledger here and the sequence was effectively counted twice). +#[test] +fn names_extend_name_ttl_live_until_delta_is_exactly_extend_to() { + let n = Names::new(); + advance(&n.env, TTL_EXTEND_TO - 10); + let now = n.seq(); + let extend_to = 100_000; + + n.client.extend_name_ttl(&n.name, &extend_to); + + assert_eq!(now + n.ttl(&n.name_key), now + extend_to); + assert_eq!(now + n.ttl(&n.reverse_key), now + extend_to); + assert_eq!(now + instance_ttl(&n.env, &n.id), now + extend_to); +} + +#[test] +fn names_extend_name_ttl_does_not_treat_extend_to_as_absolute_ledger() { + let n = Names::new(); + advance(&n.env, TTL_EXTEND_TO - 10); + let now = n.seq(); + let extend_to = 100_000; + + // Passing an absolute ledger number would extend far past the 30 days asked + // for; with relative semantics the delta stays at what was requested. + n.client.extend_name_ttl(&n.name, &extend_to); + let live_until = now + n.ttl(&n.name_key); + assert!(live_until < now + now + extend_to); + assert_eq!(live_until - now, extend_to); +} + #[test] -fn names_extend_name_ttl_is_noop_while_ttl_above_current_ledger() { - // extend_name_ttl uses the current ledger sequence as its threshold, so - // an entry whose remaining TTL exceeds that number is left untouched. +fn names_extend_name_ttl_is_noop_while_ttl_above_requested() { let n = Names::new(); - assert!(n.ttl(&n.name_key) > n.seq()); - n.client - .extend_name_ttl(&n.name, &(n.seq() + TTL_EXTEND_TO * 2)); + n.client.extend_name_ttl(&n.name, &(TTL_EXTEND_TO - 1)); assert_eq!(n.ttl(&n.name_key), TTL_EXTEND_TO); assert_eq!(n.ttl(&n.reverse_key), TTL_EXTEND_TO); } @@ -355,19 +383,28 @@ fn names_extend_name_ttl_is_capped_at_max_entry_ttl() { } #[test] -fn names_extend_name_ttl_rejects_non_future_ledger() { +fn names_bulk_renew_live_until_delta_is_exactly_extend_to() { + let n = Names::new(); + advance(&n.env, TTL_EXTEND_TO - 10); + let now = n.seq(); + let extend_to = 100_000; + let names: Vec = vec![&n.env, n.name.clone()]; + + n.client.bulk_renew(&names, &extend_to); + + assert_eq!(now + n.ttl(&n.name_key), now + extend_to); + assert_eq!(now + n.ttl(&n.reverse_key), now + extend_to); +} + +#[test] +fn names_extend_name_ttl_rejects_zero_ttl() { let n = Names::new(); - let seq = n.seq(); - assert_eq!( - n.client.try_extend_name_ttl(&n.name, &seq), - Err(Ok(NamesError::InvalidExtendLedger)) - ); assert_eq!( - n.client.try_extend_name_ttl(&n.name, &(seq - 1)), + n.client.try_extend_name_ttl(&n.name, &0), Err(Ok(NamesError::InvalidExtendLedger)) ); assert_eq!( - n.client.try_bulk_renew(&vec![&n.env, n.name.clone()], &seq), + n.client.try_bulk_renew(&vec![&n.env, n.name.clone()], &0), Err(Ok(NamesError::InvalidExtendLedger)) ); assert_eq!(n.ttl(&n.name_key), TTL_EXTEND_TO); @@ -377,9 +414,8 @@ fn names_extend_name_ttl_rejects_non_future_ledger() { fn names_missing_entry_cannot_be_extended() { let n = Names::new(); let ghost = String::from_str(&n.env, "ghost"); - let extend_to = n.seq() + TTL_EXTEND_TO; assert_eq!( - n.client.try_extend_name_ttl(&ghost, &extend_to), + n.client.try_extend_name_ttl(&ghost, &TTL_EXTEND_TO), Err(Ok(NamesError::NameNotFound)) ); assert_eq!( @@ -398,7 +434,7 @@ fn names_bulk_renew_with_missing_entry_renews_nothing() { advance(&n.env, TTL_EXTEND_TO - 10); let names: Vec = vec![&n.env, n.name.clone(), String::from_str(&n.env, "ghost")]; assert_eq!( - n.client.try_bulk_renew(&names, &(n.seq() + 1)), + n.client.try_bulk_renew(&names, &TTL_EXTEND_TO), Err(Ok(NamesError::NameNotFound)) ); assert_eq!(n.ttl(&n.name_key), 10); @@ -414,7 +450,7 @@ fn names_released_entry_is_missing() { Err(Ok(NamesError::NameNotFound)) ); assert_eq!( - n.client.try_extend_name_ttl(&n.name, &(n.seq() + 1)), + n.client.try_extend_name_ttl(&n.name, &TTL_EXTEND_TO), Err(Ok(NamesError::NameNotFound)) ); } @@ -434,7 +470,7 @@ fn names_renewal_after_expiry_fails() { let n = Names::new(); pin_instance(&n.env, &n.id); advance(&n.env, TTL_EXTEND_TO + 1); - let _ = n.client.try_extend_name_ttl(&n.name, &(n.seq() + 1)); + let _ = n.client.try_extend_name_ttl(&n.name, &TTL_EXTEND_TO); } // ── Vault ─────────────────────────────────────────────────────────────────── diff --git a/stellar/scripts/keeper/README.md b/stellar/scripts/keeper/README.md index a93c9e09..0a6abd85 100644 --- a/stellar/scripts/keeper/README.md +++ b/stellar/scripts/keeper/README.md @@ -10,7 +10,7 @@ Soroban contract storage entries have a TTL (Time-To-Live). Entries that aren't 1. **Enumerate names.** The contract keeps no on-chain name list, so the keeper rebuilds it from the contract's `register`, `update` and `release` events (Soroban RPC `getEvents`, starting at `--start-ledger`) and/or from an off-chain index file (`--index-file`, a JSON array of names, e.g. exported from `examples/indexer`). Both sources are merged. 2. **Read TTLs.** For each name it reads the persistent `Name(sha256(name))` ledger entry via `getLedgerEntries` and computes `liveUntilLedgerSeq - latestLedger`. -3. **Extend.** Names with remaining TTL below `--threshold` get a real `extend_name_ttl(name, latest + extend-to)` transaction (simulated, signed, submitted and polled to completion). +3. **Extend.** Names with remaining TTL below `--threshold` get a real `extend_name_ttl(name, extend-to)` transaction (simulated, signed, submitted and polled to completion). ## Fails closed @@ -88,7 +88,7 @@ tsx scripts/keeper/keeper.ts cost-estimate --names-count 1000 | `--start-ledger` | - | First ledger to scan events from (deployment ledger) | | `--index-file` | - | JSON array of names from an off-chain index | | `--threshold` | 17280 | Extend when remaining TTL is below this (ledgers, ~1 day) | -| `--extend-to` | 518400 | Ledgers from now to extend to (~30 days) | +| `--extend-to` | 518400 | TTL in ledgers from now to extend to (~30 days). Passed to the contract as a relative TTL, so the name ends up live until `current ledger + extend-to` (capped by the network max entry TTL) | | `--secret-key` | - | Keeper account secret key | | `--dry-run` | false | Show actions without executing | | `--rpc-url` | network default | Custom Soroban RPC URL | @@ -121,7 +121,7 @@ Topics: - symbol: "extend" - name_hash: BytesN<32> Data: - - extend_to_ledger: u32 + - extend_to: u32 // TTL in ledgers from the ledger the call ran in, not a ledger sequence ``` These events can be monitored to track Keeper activity. @@ -142,7 +142,7 @@ Example output: latest ledger 123456 found 42 registered name(s) 15/42 below threshold 17280 -extended "alice" to ledger 641856 (tx 9f1c...) +extended "alice" by 518400 ledgers, live until ~ledger 641856 (tx 9f1c...) ... done: 42 names, 15 below threshold, 15 extended ``` diff --git a/stellar/scripts/keeper/keeper-core.ts b/stellar/scripts/keeper/keeper-core.ts index 8dc8f23c..dc354b5f 100644 --- a/stellar/scripts/keeper/keeper-core.ts +++ b/stellar/scripts/keeper/keeper-core.ts @@ -45,14 +45,17 @@ export interface KeeperRpc { getNameEvents(startLedger: number, cursor?: string): Promise; /** liveUntilLedgerSeq of the persistent Name(hash) entry, or null if no live entry. */ getNameLiveUntil(nameHash: string): Promise; - /** Submit extend_name_ttl(name, extendTo) and wait for it to land. Returns the tx hash. */ + /** + * Submit extend_name_ttl(name, extendTo) and wait for it to land. Returns the tx hash. + * `extendTo` is a TTL in ledgers counted from the current ledger (not a ledger sequence). + */ extendName(name: string, extendTo: number): Promise; } export interface KeeperOptions { /** Extend names whose remaining TTL (ledgers) is below this. */ threshold: number; - /** Ledgers from now to extend to. */ + /** TTL, in ledgers from now, to extend to (relative, not an absolute ledger). */ extendTo: number; dryRun: boolean; /** First ledger to read events from (e.g. the contract deployment ledger). */ @@ -199,16 +202,20 @@ export async function runKeeper( dryRun: o.dryRun, }; - const target = latestLedger + o.extendTo; + // The contract takes a relative TTL, so pass extendTo as is. Adding latestLedger here + // would count the current ledger twice and extend far past the requested window. + const liveUntilAfter = latestLedger + o.extendTo; for (const s of risky) { if (o.dryRun) { - log(`[dry-run] would extend "${s.name}" (remaining ${s.remaining}) to ledger ${target}`); + log( + `[dry-run] would extend "${s.name}" (remaining ${s.remaining}) by ${o.extendTo} ledgers, live until ~ledger ${liveUntilAfter}`, + ); continue; } try { - const txHash = await rpc.extendName(s.name, target); + const txHash = await rpc.extendName(s.name, o.extendTo); report.extended.push({ name: s.name, txHash }); - log(`extended "${s.name}" to ledger ${target} (tx ${txHash})`); + log(`extended "${s.name}" by ${o.extendTo} ledgers, live until ~ledger ${liveUntilAfter} (tx ${txHash})`); } catch (e) { throw new KeeperError( 'submission', diff --git a/stellar/scripts/keeper/keeper.test.ts b/stellar/scripts/keeper/keeper.test.ts index af1dfa91..6c0cac70 100644 --- a/stellar/scripts/keeper/keeper.test.ts +++ b/stellar/scripts/keeper/keeper.test.ts @@ -26,6 +26,8 @@ function fakeRpc(opts: { failLatest?: boolean; }) { const extendCalls: { name: string; to: number }[] = []; + // Mutable so the fake can model the contract: extend_name_ttl(name, to) takes a TTL + // relative to the current ledger and tops the entry up to LATEST + to. const ttlByHash = new Map( Object.entries(opts.liveUntil ?? {}).map(([n, v]) => [nameHash(n), v] as const), ); @@ -48,10 +50,13 @@ function fakeRpc(opts: { extendName: async (name, to) => { if (opts.failExtendFor === name) throw new Error('tx failed'); extendCalls.push({ name, to }); + const h = nameHash(name); + const current = ttlByHash.get(h) ?? 0; + if (current - LATEST < to) ttlByHash.set(h, LATEST + to); return `tx-${name}`; }, }; - return { rpc, extendCalls }; + return { rpc, extendCalls, liveUntilOf: (n: string) => ttlByHash.get(nameHash(n)) }; } const ev = (kind: DecodedEvent['kind'], name: string): DecodedEvent => ({ ledger: 10, kind, name }); @@ -123,20 +128,32 @@ describe('enumeration', () => { }); describe('ttl reads and extension', () => { - it('extends only names below the threshold, to latest + extendTo', async () => { + it('extends only names below the threshold, passing extendTo as a relative TTL', async () => { const { rpc, extendCalls } = fakeRpc({ pages: [page([ev('register', 'alice'), ev('register', 'bob'), ev('register', 'carol')])], liveUntil: { alice: LATEST + 500, bob: LATEST + 5000, carol: LATEST + 999 }, }); const report = await runKeeper(rpc, base); expect(extendCalls).toEqual([ - { name: 'alice', to: LATEST + 500_000 }, - { name: 'carol', to: LATEST + 500_000 }, + { name: 'alice', to: 500_000 }, + { name: 'carol', to: 500_000 }, ]); expect(report.atRisk).toBe(2); expect(report.extended.map((e) => e.txHash)).toEqual(['tx-alice', 'tx-carol']); }); + it('the resulting liveUntil is latest + extendTo, not latest counted twice', async () => { + const { rpc, extendCalls, liveUntilOf } = fakeRpc({ + pages: [page([ev('register', 'alice')])], + liveUntil: { alice: LATEST + 500 }, + }); + await runKeeper(rpc, base); + const sent = extendCalls[0].to; + expect(sent).toBe(base.extendTo); + expect(sent).toBeLessThan(LATEST); // a relative TTL, never an absolute ledger + expect(liveUntilOf('alice')! - LATEST).toBe(base.extendTo); + }); + it('treats remaining == threshold as healthy', async () => { const { rpc, extendCalls } = fakeRpc({ pages: [page([ev('register', 'alice')])], diff --git a/stellar/wraith-names/src/lib.rs b/stellar/wraith-names/src/lib.rs index bb7244f1..c5266fc5 100644 --- a/stellar/wraith-names/src/lib.rs +++ b/stellar/wraith-names/src/lib.rs @@ -453,20 +453,19 @@ impl WraithNamesContract { /// Renew (extend TTL for) multiple names in a single atomic transaction. /// + /// `extend_to` is a TTL in ledgers counted from the current ledger, not a + /// ledger sequence. Each name ends up live for at least `extend_to` more + /// ledgers (capped by the network's max entry TTL). + /// /// All names must exist. If any name is not found, the entire operation /// reverts. - pub fn bulk_renew( - env: Env, - names: Vec, - extend_to_ledger: u32, - ) -> Result<(), NamesError> { + pub fn bulk_renew(env: Env, names: Vec, extend_to: u32) -> Result<(), NamesError> { let count = names.len(); if count > BULK_LIMIT { return Err(NamesError::BulkLimitExceeded); } - let current_ledger = env.ledger().sequence(); - if extend_to_ledger <= current_ledger { + if extend_to == 0 { return Err(NamesError::InvalidExtendLedger); } @@ -493,13 +492,13 @@ impl WraithNamesContract { env.storage() .persistent() - .extend_ttl(&name_key, current_ledger, extend_to_ledger); + .extend_ttl(&name_key, extend_to, extend_to); env.storage() .persistent() - .extend_ttl(&reverse_key, current_ledger, extend_to_ledger); + .extend_ttl(&reverse_key, extend_to, extend_to); env.events() - .publish((symbol_short!("extend"), name_hash), extend_to_ledger); + .publish((symbol_short!("extend"), name_hash), extend_to); } let mut name_hashes: Vec> = Vec::new(&env); @@ -507,10 +506,8 @@ impl WraithNamesContract { let n = names.get(ni).unwrap(); name_hashes.push_back(Self::hash_name(&env, &n)); } - env.events().publish( - (symbol_short!("blk_renew"),), - (name_hashes, extend_to_ledger), - ); + env.events() + .publish((symbol_short!("blk_renew"),), (name_hashes, extend_to)); // Emit metric event — one renewal per name in the batch. emit_metric( @@ -849,19 +846,16 @@ impl WraithNamesContract { Ok(entry.name) } - /// Extend TTL for persistent storage entries only. - /// Extend the TTL of a registered name to a future ledger. + /// Extend the TTL of a registered name (its entry, reverse entry and the + /// contract instance) so it stays live for at least `extend_to` more ledgers. + /// + /// `extend_to` is a TTL in ledgers counted from the current ledger, not a + /// ledger sequence, and is capped by the network's max entry TTL. Entries + /// that already have at least that much TTL left are left untouched. /// This is a permissionless function that anyone can call. - /// Idempotent: calling twice in the same ledger has no additional effect. - pub fn extend_name_ttl( - env: Env, - name: String, - extend_to_ledger: u32, - ) -> Result<(), NamesError> { + pub fn extend_name_ttl(env: Env, name: String, extend_to: u32) -> Result<(), NamesError> { Self::require_not_paused(&env)?; - // Validate that extend_to_ledger is in the future - let current_ledger = env.ledger().sequence(); - if extend_to_ledger <= current_ledger { + if extend_to == 0 { return Err(NamesError::InvalidExtendLedger); } @@ -882,20 +876,18 @@ impl WraithNamesContract { ); let reverse_key = DataKey::Reverse(meta_hash); - // Extend TTLs to the specified ledger + // Top up to `extend_to` ledgers from now; no-op if already above it. env.storage() .persistent() - .extend_ttl(&name_key, current_ledger, extend_to_ledger); + .extend_ttl(&name_key, extend_to, extend_to); env.storage() .persistent() - .extend_ttl(&reverse_key, current_ledger, extend_to_ledger); - env.storage() - .instance() - .extend_ttl(current_ledger, extend_to_ledger); + .extend_ttl(&reverse_key, extend_to, extend_to); + env.storage().instance().extend_ttl(extend_to, extend_to); // Emit extend event for observability env.events() - .publish((symbol_short!("extend"), name_hash), extend_to_ledger); + .publish((symbol_short!("extend"), name_hash), extend_to); // Emit metric event. emit_metric( @@ -1796,7 +1788,7 @@ mod test { client.register(&owner, &name2, &Bytes::from_slice(&env, &[2u8; 64])); let names = soroban_sdk::vec![&env, name1.clone(), name2.clone()]; - let extend_to = env.ledger().sequence() + 10000; + let extend_to = 10000u32; let result = client.try_bulk_renew(&names, &extend_to); assert_eq!(result, Ok(Ok(()))); } @@ -1821,7 +1813,7 @@ mod test { String::from_str(&env, "exists"), String::from_str(&env, "ghost"), ]; - let extend_to = env.ledger().sequence() + 10000; + let extend_to = 10000u32; let result = client.try_bulk_renew(&names, &extend_to); assert_eq!(result, Err(Ok(NamesError::NameNotFound))); } @@ -1838,7 +1830,7 @@ mod test { for i in 0..21 { names_vec.push_back(String::from_str(&env, &format!("name{}", i))); } - let extend_to = env.ledger().sequence() + 10000; + let extend_to = 10000u32; let result = client.try_bulk_renew(&names_vec, &extend_to); assert_eq!(result, Err(Ok(NamesError::BulkLimitExceeded))); } @@ -2089,7 +2081,7 @@ mod test { client.pause(&admin); // extend_name_ttl should be rejected - let extend_to = env.ledger().sequence() + 1000; + let extend_to = 1000u32; let result = client.try_extend_name_ttl(&name, &extend_to); assert_eq!(result, Err(Ok(NamesError::Paused))); } diff --git a/stellar/wraith-names/tests/extend_name_ttl.rs b/stellar/wraith-names/tests/extend_name_ttl.rs index c020572c..779aa982 100644 --- a/stellar/wraith-names/tests/extend_name_ttl.rs +++ b/stellar/wraith-names/tests/extend_name_ttl.rs @@ -33,8 +33,8 @@ fn test_extend_name_ttl_happy_path() { register_name(&env, &client, &owner, "alice", &meta); - // Extend to ledger 10000 - let extend_to = u32::try_from(env.ledger().sequence() + 10000).unwrap(); + // Extend to 10000 ledgers from now + let extend_to = 10000; let result = client.try_extend_name_ttl(&name, &extend_to); assert_eq!(result, Ok(Ok(()))); } @@ -51,7 +51,7 @@ fn test_extend_name_ttl_permissionless() { register_name(&env, &client, &owner, "bob", &meta); // Other party extends the TTL - let extend_to = u32::try_from(env.ledger().sequence() + 5000).unwrap(); + let extend_to = 5000; let result = client.try_extend_name_ttl(&name, &extend_to); assert_eq!(result, Ok(Ok(()))); } @@ -67,7 +67,7 @@ fn test_extend_name_ttl_idempotent() { register_name(&env, &client, &owner, "carol", &meta); // First extend - let extend_to = u32::try_from(env.ledger().sequence() + 7000).unwrap(); + let extend_to = 7000; let result1 = client.try_extend_name_ttl(&name, &extend_to); assert_eq!(result1, Ok(Ok(()))); @@ -82,12 +82,12 @@ fn test_extend_name_ttl_nonexistent_name_rejected() { let (env, client) = setup(); let name = String::from_str(&env, "ghost"); - let extend_to = u32::try_from(env.ledger().sequence() + 5000).unwrap(); + let extend_to = 5000; let result = client.try_extend_name_ttl(&name, &extend_to); assert_eq!(result, Err(Ok(NamesError::NameNotFound))); } -/// 5. Invalid extend_to_ledger: extending to current ledger or past is rejected. +/// 5. Invalid extend_to: a zero TTL is rejected. #[test] fn test_extend_name_ttl_invalid_extend_ledger() { let (env, client) = setup(); @@ -97,17 +97,7 @@ fn test_extend_name_ttl_invalid_extend_ledger() { register_name(&env, &client, &owner, "dave", &meta); - let current_ledger = env.ledger().sequence(); - - // Try to extend to current ledger - let result = client.try_extend_name_ttl(&name, &u32::try_from(current_ledger).unwrap()); - assert_eq!(result, Err(Ok(NamesError::InvalidExtendLedger))); - - // Try to extend to past ledger - let result = client.try_extend_name_ttl( - &name, - &u32::try_from(current_ledger.saturating_sub(1)).unwrap_or(0), - ); + let result = client.try_extend_name_ttl(&name, &0); assert_eq!(result, Err(Ok(NamesError::InvalidExtendLedger))); } @@ -125,8 +115,8 @@ fn test_extend_name_ttl_multiple_names() { register_name(&env, &client, &owner, "frank", &meta); // Extend both names to different ledgers - let extend_to1 = u32::try_from(env.ledger().sequence() + 3000).unwrap(); - let extend_to2 = u32::try_from(env.ledger().sequence() + 8000).unwrap(); + let extend_to1 = 3000; + let extend_to2 = 8000; let result1 = client.try_extend_name_ttl(&name1, &extend_to1); assert_eq!(result1, Ok(Ok(()))); @@ -150,7 +140,7 @@ fn test_extend_name_ttl_after_update() { client.update(&owner, &name, &meta2); // Extend the updated name - let extend_to = u32::try_from(env.ledger().sequence() + 4000).unwrap(); + let extend_to = 4000; let result = client.try_extend_name_ttl(&name, &extend_to); assert_eq!(result, Ok(Ok(()))); @@ -175,7 +165,7 @@ fn test_extend_name_ttl_after_release_and_reregister() { register_name(&env, &client, &owner, "henry", &meta); // Extend the re-registered name - let extend_to = u32::try_from(env.ledger().sequence() + 6000).unwrap(); + let extend_to = 6000; let result = client.try_extend_name_ttl(&name, &extend_to); assert_eq!(result, Ok(Ok(()))); } @@ -190,11 +180,11 @@ fn test_extend_name_ttl_emits_event() { register_name(&env, &client, &owner, "ivy", &meta); - let extend_to = u32::try_from(env.ledger().sequence() + 9000).unwrap(); + let extend_to = 9000; client.extend_name_ttl(&name, &extend_to); // Check that an event was published (test framework should capture this) - // The event should contain the name hash and extend_to_ledger + // The event should contain the name hash and extend_to let events = env.events().all(); assert!(!events.is_empty(), "extend_name_ttl should emit an event"); } diff --git a/stellar/wraith-names/tests/metrics.rs b/stellar/wraith-names/tests/metrics.rs index b854b18c..c9417163 100644 --- a/stellar/wraith-names/tests/metrics.rs +++ b/stellar/wraith-names/tests/metrics.rs @@ -86,7 +86,7 @@ fn extend_name_ttl_emits_renew_count_metric() { let name = String::from_str(&env, "alice"); client.register(&owner, &name, &meta(&env, 1)); - let extend_to = env.ledger().sequence() + 10_000; + let extend_to = 10_000u32; client.extend_name_ttl(&name, &extend_to); let (topics, data) = only_metric_event(&env); @@ -105,7 +105,7 @@ fn bulk_renew_emits_renew_count_metric_carrying_batch_size() { String::from_str(&env, "alpha"), String::from_str(&env, "beta"), ]; - let extend_to = env.ledger().sequence() + 10_000; + let extend_to = 10_000u32; client.bulk_renew(&names, &extend_to); let (topics, data) = only_metric_event(&env); diff --git a/stellar/wraith-names/tests/names_bulk.rs b/stellar/wraith-names/tests/names_bulk.rs index c38ae987..05dd2462 100644 --- a/stellar/wraith-names/tests/names_bulk.rs +++ b/stellar/wraith-names/tests/names_bulk.rs @@ -246,7 +246,7 @@ fn test_bulk_renew_all_succeed() { String::from_str(&env, "beta"), ], ); - let extend_to = env.ledger().sequence() + 10000; + let extend_to = 10000u32; let result = client.try_bulk_renew(&names, &extend_to); assert_eq!(result, Ok(Ok(()))); @@ -277,7 +277,7 @@ fn test_bulk_renew_atomic_revert_on_missing() { String::from_str(&env, "ghost"), ], ); - let extend_to = env.ledger().sequence() + 10000; + let extend_to = 10000u32; let result = client.try_bulk_renew(&names, &extend_to); assert_eq!(result, Err(Ok(NamesError::NameNotFound))); @@ -292,13 +292,13 @@ fn test_bulk_renew_exceeds_limit() { for i in 0..21 { names.push_back(String::from_str(&env, &format!("n{}", i))); } - let extend_to = env.ledger().sequence() + 10000; + let extend_to = 10000u32; let result = client.try_bulk_renew(&names, &extend_to); assert_eq!(result, Err(Ok(NamesError::BulkLimitExceeded))); } -/// 11. bulk_renew with invalid extend_to_ledger (past) is rejected. +/// 11. bulk_renew with a zero TTL is rejected. #[test] fn test_bulk_renew_invalid_extend_ledger() { let (env, client) = setup(); @@ -311,8 +311,7 @@ fn test_bulk_renew_invalid_extend_ledger() { ); let names = Vec::from_array(&env, [String::from_str(&env, "test")]); - let current = env.ledger().sequence(); - let result = client.try_bulk_renew(&names, ¤t); + let result = client.try_bulk_renew(&names, &0); assert_eq!(result, Err(Ok(NamesError::InvalidExtendLedger))); } @@ -340,7 +339,7 @@ fn test_bulk_renew_emits_per_name_events() { String::from_str(&env, "beta"), ], ); - let extend_to = env.ledger().sequence() + 10000; + let extend_to = 10000u32; client.bulk_renew(&names, &extend_to); let events = env.events().all(); From f57913387957adce352268b475750181281cdf56 Mon Sep 17 00:00:00 2001 From: annacodervibe Date: Wed, 21 Oct 2026 09:18:27 +0100 Subject: [PATCH 3/3] fix(stellar): declare commander, refresh bindings and bench baseline for relative TTL --- .github/workflows/ci.yml | 4 ++++ package.json | 4 +++- pnpm-lock.yaml | 3 +++ stellar/PERF.md | 18 ++++++++++++++-- stellar/bench/baseline.json | 8 +++---- .../typescript/wraith-names/src/index.ts | 21 ++++++++++++------- 6 files changed, 44 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02ef1c5b..ba73ce76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,10 @@ jobs: run: pnpm test:stellar-bindings working-directory: . + - name: Test names TTL keeper + run: pnpm test:stellar-keeper + working-directory: . + # The metrics indexer is a standalone Node package with its own lockfile # (like evm/ and solana/). Install and test it so its RPC/health # guarantees stay enforced; the root pnpm install does not cover it. diff --git a/package.json b/package.json index 6492820b..dc437755 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "bindings:stellar": "tsx stellar/scripts/generate-bindings.ts", "build:stellar": "pnpm bindings:stellar && cd stellar && cargo build --target wasm32-unknown-unknown --release", - "test:stellar-bindings": "vitest run stellar/scripts/stealth-batch-sender-bindings.test.ts" + "test:stellar-bindings": "vitest run stellar/scripts/stealth-batch-sender-bindings.test.ts", + "test:stellar-keeper": "vitest run stellar/scripts/keeper/keeper.test.ts" }, "pnpm": { "onlyBuiltDependencies": ["esbuild"] @@ -14,6 +15,7 @@ "devDependencies": { "@stellar/stellar-sdk": "^15.1.0", "@types/node": "^22.0.0", + "commander": "^14.0.3", "tsx": "^4.19.2", "typescript": "^5.7.0", "vitest": "^2.1.9" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24a43a53..2adbf9c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.19 + commander: + specifier: ^14.0.3 + version: 14.0.3 tsx: specifier: ^4.19.2 version: 4.22.3 diff --git a/stellar/PERF.md b/stellar/PERF.md index b252aac7..c8c75f41 100644 --- a/stellar/PERF.md +++ b/stellar/PERF.md @@ -16,6 +16,20 @@ When adding features that deliberately increase instruction or resource usage pa 3. Re-run `cargo bench -p wraith-stellar-bench --bench gas -- --format json --out results.json`. 4. Commit the new `stellar/bench/baseline.json` and updated `stellar/PERF.md`. +### Names TTL extension is relative (issue #228) + +`wraith-names::extend_name_ttl` and `bulk_renew` take `extend_to` as a TTL in +ledgers from now. Before, the contract passed the current ledger as the +`extend_ttl` threshold, so in the bench the contract instance top-up was skipped. +It is now performed, because the threshold is `extend_to`. `extend_name_ttl::existing` goes from 61,349 to +63,741 instructions (+3.9%) and from 5,506 to 6,354 memory bytes (+15.4%, about +850 bytes). Ledger entries, read/write bytes and event bytes are unchanged, and +no other benched op moves, including `bulk_renew` (which does not extend the +instance). Dropping the instance top-up brings the op down to 57,407 +instructions and 5,118 bytes, but then the keeper would extend names while the +contract itself could expire, so the cost is the extension doing the work it +was meant to do. + ### Received-amount check (issue #227) `stealth-sender::send`, `stealth-sender::batch_send` and @@ -101,7 +115,7 @@ ownership checks. ## Current Numbers -These are the harness results auto-updated from `develop` (measured 2026-10-16, commit `71bdd4edb2ee`). +These are the harness results auto-updated from `develop` (measured 2026-10-21, commit `198cedc001f1`). | Contract | Function | Parameters | Instructions | Mem bytes | Read entries | Write entries | Read bytes | Write bytes | Event bytes | |---|---|---:|---:|---:|---:|---:|---:|---:|---:| @@ -134,7 +148,7 @@ These are the harness results auto-updated from `develop` (measured 2026-10-16, | wraith-names | bulk_renew | count=5 | 351158 | 25489 | 11 | 0 | 2584 | 0 | 1032 | | wraith-names | bulk_renew | count=10 | 718431 | 51559 | 21 | 0 | 5064 | 0 | 1812 | | wraith-names | bulk_renew | count=20 | 1511175 | 111799 | 41 | 0 | 10024 | 0 | 3372 | -| wraith-names | extend_name_ttl | existing | 61349 | 5506 | 4 | 0 | 656 | 0 | 264 | +| wraith-names | extend_name_ttl | existing | 63741 | 6354 | 4 | 0 | 656 | 0 | 264 | | stealth-splitter | create_split | beneficiaries=5 | 121823 | 14396 | 1 | 2 | 180 | 1300 | 544 | | stealth-splitter | create_split | beneficiaries=15 | 230548 | 38036 | 1 | 2 | 180 | 2700 | 544 | | stealth-splitter | create_split | beneficiaries=25 | 343273 | 77676 | 1 | 2 | 180 | 4100 | 544 | diff --git a/stellar/bench/baseline.json b/stellar/bench/baseline.json index 634fc442..244e0503 100644 --- a/stellar/bench/baseline.json +++ b/stellar/bench/baseline.json @@ -1,6 +1,6 @@ { - "generated_at": "1792167500", - "commit": "71bdd4edb2ee33e5b88e36b6832cc633bc5704e9", + "generated_at": "1792569345", + "commit": "198cedc001f12cf21a1830a00e6fdc32a31d9712", "threshold_pct": 5, "results": [ { @@ -355,8 +355,8 @@ "contract": "wraith-names", "function": "extend_name_ttl", "params": "existing", - "instructions": 61349, - "mem_bytes": 5506, + "instructions": 63741, + "mem_bytes": 6354, "read_entries": 4, "write_entries": 0, "read_bytes": 656, diff --git a/stellar/bindings/typescript/wraith-names/src/index.ts b/stellar/bindings/typescript/wraith-names/src/index.ts index 844e2a21..50e66770 100644 --- a/stellar/bindings/typescript/wraith-names/src/index.ts +++ b/stellar/bindings/typescript/wraith-names/src/index.ts @@ -394,10 +394,14 @@ export interface Client { * Construct and simulate a bulk_renew transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Renew (extend TTL for) multiple names in a single atomic transaction. * + * `extend_to` is a TTL in ledgers counted from the current ledger, not a + * ledger sequence. Each name ends up live for at least `extend_to` more + * ledgers (capped by the network's max entry TTL). + * * All names must exist. If any name is not found, the entire operation * reverts. */ - bulk_renew: ({names, extend_to_ledger}: {names: Array, extend_to_ledger: u32}, options?: MethodOptions) => Promise>> + bulk_renew: ({names, extend_to}: {names: Array, extend_to: u32}, options?: MethodOptions) => Promise>> /** * Construct and simulate a claim_name transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -482,12 +486,15 @@ export interface Client { /** * Construct and simulate a extend_name_ttl transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - * Extend TTL for persistent storage entries only. - * Extend the TTL of a registered name to a future ledger. + * Extend the TTL of a registered name (its entry, reverse entry and the + * contract instance) so it stays live for at least `extend_to` more ledgers. + * + * `extend_to` is a TTL in ledgers counted from the current ledger, not a + * ledger sequence, and is capped by the network's max entry TTL. Entries + * that already have at least that much TTL left are left untouched. * This is a permissionless function that anyone can call. - * Idempotent: calling twice in the same ledger has no additional effect. */ - extend_name_ttl: ({name, extend_to_ledger}: {name: string, extend_to_ledger: u32}, options?: MethodOptions) => Promise>> + extend_name_ttl: ({name, extend_to}: {name: string, extend_to: u32}, options?: MethodOptions) => Promise>> /** * Construct and simulate a pending_rotation transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -624,7 +631,7 @@ export class Client extends ContractClient { "AAAAAAAAACdSZXR1cm5zIHRydWUgaWYgdGhlIGNvbnRyYWN0IGlzIHBhdXNlZC4AAAAACWlzX3BhdXNlZAAAAAAAAAAAAAABAAAAAQ==", "AAAAAAAAADNDdXJyZW50IHByb3RvY29sLWxldmVsIGdvdmVybmFuY2UgcXVvcnVtIHRocmVzaG9sZC4AAAAACXRocmVzaG9sZAAAAAAAAAAAAAABAAAABA==", "AAAAAgAAAA1TdG9yYWdlIGtleXMuAAAAAAAAAAAAAAdEYXRhS2V5AAAAAAsAAAABAAAAKU1hcHMgbmFtZSBoYXNoIChCeXRlc048MzI+KSB0byBOYW1lRW50cnkuAAAAAAAABE5hbWUAAAABAAAD7gAAACAAAAABAAAAPlJldmVyc2UgbG9va3VwOiBtZXRhLWFkZHJlc3MgaGFzaCAoQnl0ZXNOPDMyPikgdG8gbmFtZSBzdHJpbmcuAAAAAAAHUmV2ZXJzZQAAAAABAAAD7gAAACAAAAABAAAALVJlcGxheSBwcm90ZWN0aW9uIGZvciBzaWduZWQgb24tYmVoYWxmIGNhbGxzLgAAAAAAAAZSZXBsYXkAAAAAAAEAAAPuAAAAIAAAAAEAAAAbR3VhcmRpYW4gY29uZmlnIGZvciBhIG5hbWUuAAAAAAlHdWFyZGlhbnMAAAAAAAABAAAD7gAAACAAAAABAAAAJVBlbmRpbmcgcmVjb3ZlcnkgcHJvcG9zYWwgZm9yIGEgbmFtZS4AAAAAAAAIUmVjb3ZlcnkAAAABAAAD7gAAACAAAAAAAAAAFFBhdXNlIGFkbWluIGFkZHJlc3MuAAAABUFkbWluAAAAAAAAAAAAAB9XaGV0aGVyIHRoZSBjb250cmFjdCBpcyBwYXVzZWQuAAAAAAZQYXVzZWQAAAAAAAAAAAAuUHJvdG9jb2wtbGV2ZWwgZ292ZXJuYW5jZSBtdWx0aXNpZyBzaWduZXIgc2V0LgAAAAAAD011bHRpc2lnU2lnbmVycwAAAAAAAAAANFByb3RvY29sLWxldmVsIGdvdmVybmFuY2UgbXVsdGlzaWcgcXVvcnVtIHRocmVzaG9sZC4AAAARTXVsdGlzaWdUaHJlc2hvbGQAAAAAAAAAAAAAOFBlbmRpbmcgcHJvdG9jb2wtbGV2ZWwgc2lnbmVyLXJvdGF0aW9uIHByb3Bvc2FsLCBpZiBhbnkuAAAAD1BlbmRpbmdSb3RhdGlvbgAAAAAAAAAAMFBlbmRpbmcgYXVjdGlvbi1hZG1pbiByb3RhdGlvbiBwcm9wb3NhbCwgaWYgYW55LgAAABtQZW5kaW5nQXVjdGlvbkFkbWluUm90YXRpb24A", - "AAAAAAAAAJRSZW5ldyAoZXh0ZW5kIFRUTCBmb3IpIG11bHRpcGxlIG5hbWVzIGluIGEgc2luZ2xlIGF0b21pYyB0cmFuc2FjdGlvbi4KCkFsbCBuYW1lcyBtdXN0IGV4aXN0LiBJZiBhbnkgbmFtZSBpcyBub3QgZm91bmQsIHRoZSBlbnRpcmUgb3BlcmF0aW9uCnJldmVydHMuAAAACmJ1bGtfcmVuZXcAAAAAAAIAAAAAAAAABW5hbWVzAAAAAAAD6gAAABAAAAAAAAAAEGV4dGVuZF90b19sZWRnZXIAAAAEAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAAKTmFtZXNFcnJvcgAA", + "AAAAAAAAAVNSZW5ldyAoZXh0ZW5kIFRUTCBmb3IpIG11bHRpcGxlIG5hbWVzIGluIGEgc2luZ2xlIGF0b21pYyB0cmFuc2FjdGlvbi4KCmBleHRlbmRfdG9gIGlzIGEgVFRMIGluIGxlZGdlcnMgY291bnRlZCBmcm9tIHRoZSBjdXJyZW50IGxlZGdlciwgbm90IGEKbGVkZ2VyIHNlcXVlbmNlLiBFYWNoIG5hbWUgZW5kcyB1cCBsaXZlIGZvciBhdCBsZWFzdCBgZXh0ZW5kX3RvYCBtb3JlCmxlZGdlcnMgKGNhcHBlZCBieSB0aGUgbmV0d29yaydzIG1heCBlbnRyeSBUVEwpLgoKQWxsIG5hbWVzIG11c3QgZXhpc3QuIElmIGFueSBuYW1lIGlzIG5vdCBmb3VuZCwgdGhlIGVudGlyZSBvcGVyYXRpb24KcmV2ZXJ0cy4AAAAACmJ1bGtfcmVuZXcAAAAAAAIAAAAAAAAABW5hbWVzAAAAAAAD6gAAABAAAAAAAAAACWV4dGVuZF90bwAAAAAAAAQAAAABAAAD6QAAA+0AAAAAAAAH0AAAAApOYW1lc0Vycm9yAAA=", "AAAAAAAAAFZDbGFpbSBhIHdvbiBhdWN0aW9uOiByZWdpc3RlcnMgdGhlIG5hbWUgdG8gdGhlIHdpbm5lciB3aXRoIHRoZWlyCnN0ZWFsdGggbWV0YS1hZGRyZXNzLgAAAAAACmNsYWltX25hbWUAAAAAAAMAAAAAAAAABndpbm5lcgAAAAAAEwAAAAAAAAAEbmFtZQAAABAAAAAAAAAAFHN0ZWFsdGhfbWV0YV9hZGRyZXNzAAAADgAAAAEAAAPpAAAD7QAAAAAAAAfQAAAADEF1Y3Rpb25FcnJvcg==", "AAAAAAAAAIdDb21taXQgYSBzZWFsZWQgYmlkLiBgY29tbWl0bWVudGAgaGlkZXMgdGhlIGJpZCBhbW91bnQ7IGBkZXBvc2l0YCBpcwp0cmFuc2ZlcnJlZCB0byB0aGUgY29udHJhY3QgYW5kIG11c3QgY292ZXIgdGhlIGJpZCByZXZlYWxlZCBsYXRlci4AAAAACmNvbW1pdF9iaWQAAAAAAAQAAAAAAAAABmJpZGRlcgAAAAAAEwAAAAAAAAAEbmFtZQAAABAAAAAAAAAACmNvbW1pdG1lbnQAAAAAA+4AAAAgAAAAAAAAAAdkZXBvc2l0AAAAAAsAAAABAAAD6QAAA+0AAAAAAAAH0AAAAAxBdWN0aW9uRXJyb3I=", "AAAAAAAAAERSZXZlYWwgYSBwcmV2aW91c2x5IGNvbW1pdHRlZCBiaWQgYnkgZGlzY2xvc2luZyB0aGUgYW1vdW50IGFuZCBzYWx0LgAAAApyZXZlYWxfYmlkAAAAAAAEAAAAAAAAAAZiaWRkZXIAAAAAABMAAAAAAAAABG5hbWUAAAAQAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAABHNhbHQAAAPuAAAAIAAAAAEAAAPpAAAD7QAAAAAAAAfQAAAADEF1Y3Rpb25FcnJvcg==", @@ -638,7 +645,7 @@ export class Client extends ContractClient { "AAAAAAAAAIhTdGFydCBhIHNlYWxlZC1iaWQgYXVjdGlvbiBmb3IgYSBwcmVtaXVtIG5hbWUgKDw9IDQgY2hhcnMsIHRvcC1sZXZlbCkuClBlcm1pc3Npb25sZXNzOiBhbnlvbmUgbWF5IG9wZW4gdGhlIGF1Y3Rpb24gZm9yIGFuIGVsaWdpYmxlIG5hbWUuAAAADXN0YXJ0X2F1Y3Rpb24AAAAAAAABAAAAAAAAAARuYW1lAAAAEAAAAAEAAAPpAAAD7QAAAAAAAAfQAAAADEF1Y3Rpb25FcnJvcg==", "AAAAAAAAAC9SZWFkIHRoZSBhdWN0aW9uIGNvbmZpZ3VyYXRpb24sIGlmIGluaXRpYWxpemVkLgAAAAAOYXVjdGlvbl9jb25maWcAAAAAAAAAAAABAAAD6AAAB9AAAAANQXVjdGlvbkNvbmZpZwAAAA==", "AAAAAAAAAMxTZXR0bGUgYW4gYXVjdGlvbiBhZnRlciB0aGUgcmV2ZWFsIHBoYXNlOiBwYXlzIHRoZSB3aW5uaW5nIGJpZCB0byB0aGUKdHJlYXN1cnkgYW5kIHJlZnVuZHMgdGhlIHdpbm5lcidzIGV4Y2VzcyBkZXBvc2l0LiBQZXJtaXNzaW9ubGVzcyBzbwpmdW5kcyBjYW4gbmV2ZXIgYmUgdHJhcHBlZCwgb3BlcmF0ZWQgYnkgdGhlIGFkbWluIHBlciB0aGUgcnVuYm9vay4AAAAOc2V0dGxlX2F1Y3Rpb24AAAAAAAEAAAAAAAAABG5hbWUAAAAQAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAAMQXVjdGlvbkVycm9y", - "AAAAAAAAAOZFeHRlbmQgVFRMIGZvciBwZXJzaXN0ZW50IHN0b3JhZ2UgZW50cmllcyBvbmx5LgpFeHRlbmQgdGhlIFRUTCBvZiBhIHJlZ2lzdGVyZWQgbmFtZSB0byBhIGZ1dHVyZSBsZWRnZXIuClRoaXMgaXMgYSBwZXJtaXNzaW9ubGVzcyBmdW5jdGlvbiB0aGF0IGFueW9uZSBjYW4gY2FsbC4KSWRlbXBvdGVudDogY2FsbGluZyB0d2ljZSBpbiB0aGUgc2FtZSBsZWRnZXIgaGFzIG5vIGFkZGl0aW9uYWwgZWZmZWN0LgAAAAAAD2V4dGVuZF9uYW1lX3R0bAAAAAACAAAAAAAAAARuYW1lAAAAEAAAAAAAAAAQZXh0ZW5kX3RvX2xlZGdlcgAAAAQAAAABAAAD6QAAA+0AAAAAAAAH0AAAAApOYW1lc0Vycm9yAAA=", + "AAAAAAAAAZlFeHRlbmQgdGhlIFRUTCBvZiBhIHJlZ2lzdGVyZWQgbmFtZSAoaXRzIGVudHJ5LCByZXZlcnNlIGVudHJ5IGFuZCB0aGUKY29udHJhY3QgaW5zdGFuY2UpIHNvIGl0IHN0YXlzIGxpdmUgZm9yIGF0IGxlYXN0IGBleHRlbmRfdG9gIG1vcmUgbGVkZ2Vycy4KCmBleHRlbmRfdG9gIGlzIGEgVFRMIGluIGxlZGdlcnMgY291bnRlZCBmcm9tIHRoZSBjdXJyZW50IGxlZGdlciwgbm90IGEKbGVkZ2VyIHNlcXVlbmNlLCBhbmQgaXMgY2FwcGVkIGJ5IHRoZSBuZXR3b3JrJ3MgbWF4IGVudHJ5IFRUTC4gRW50cmllcwp0aGF0IGFscmVhZHkgaGF2ZSBhdCBsZWFzdCB0aGF0IG11Y2ggVFRMIGxlZnQgYXJlIGxlZnQgdW50b3VjaGVkLgpUaGlzIGlzIGEgcGVybWlzc2lvbmxlc3MgZnVuY3Rpb24gdGhhdCBhbnlvbmUgY2FuIGNhbGwuAAAAAAAAD2V4dGVuZF9uYW1lX3R0bAAAAAACAAAAAAAAAARuYW1lAAAAEAAAAAAAAAAJZXh0ZW5kX3RvAAAAAAAABAAAAAEAAAPpAAAD7QAAAAAAAAfQAAAACk5hbWVzRXJyb3IAAA==", "AAAAAAAAAC1UaGUgcGVuZGluZyBzaWduZXItcm90YXRpb24gcHJvcG9zYWwsIGlmIGFueS4AAAAAAAAQcGVuZGluZ19yb3RhdGlvbgAAAAAAAAABAAAD6AAAB9AAAAAQUm90YXRpb25Qcm9wb3NhbA==", "AAAAAAAAAEFVcGRhdGUgYSBuYW1lIG9uIGJlaGFsZiBvZiBhbiBvd25lciB1c2luZyBhIHNpZ25lZCBhdXRob3JpemF0aW9uLgAAAAAAABB1cGRhdGVfb25fYmVoYWxmAAAABQAAAAAAAAAFb3duZXIAAAAAAAATAAAAAAAAAARuYW1lAAAAEAAAAAAAAAAQbmV3X21ldGFfYWRkcmVzcwAAAA4AAAAAAAAACXNpZ25hdHVyZQAAAAAAA+4AAABAAAAAAAAAAAZleHBpcnkAAAAAAAYAAAABAAAD6QAAA+0AAAAAAAAH0AAAAApOYW1lc0Vycm9yAAA=", "AAAAAQAAACJHdWFyZGlhbiBjb25maWd1cmF0aW9uIGZvciBhIG5hbWUuAAAAAAAAAAAADkd1YXJkaWFuQ29uZmlnAAAAAAACAAAAAAAAAAlndWFyZGlhbnMAAAAAAAPqAAAAEwAAAAAAAAAJdGhyZXNob2xkAAAAAAAABA==",