this repo has no description
1/**
2 * AttoshiStateDO - Durable Object for UTXO state management
3 * SQLite-backed index for fast queries
4 */
5
6import { DurableObject } from "cloudflare:workers";
7import type { AttoshiEnv, UTXO, TransactionOutput } from "../types.js";
8
9interface UtxoRow {
10 tx_id: string;
11 output_index: number;
12 owner: string;
13 amount: number;
14 created_at: string;
15}
16
17interface IssuanceRow {
18 did_hash: string;
19 did: string;
20 tx_id: string;
21 amount: number;
22 sequence: number;
23 issued_at: string;
24}
25
26interface SupplyRow {
27 total_issued: number;
28 burned: number;
29 issuance_count: number;
30}
31
32export class AttoshiStateDO extends DurableObject<AttoshiEnv> {
33 private sql: SqlStorage;
34 private initialized = false;
35
36 constructor(ctx: DurableObjectState, env: AttoshiEnv) {
37 super(ctx, env);
38 this.sql = ctx.storage.sql;
39 }
40
41 private initSchema() {
42 if (this.initialized) return;
43
44 this.sql.exec(`
45 -- UTXO index (denormalized from repository)
46 CREATE TABLE IF NOT EXISTS utxos (
47 tx_id TEXT NOT NULL,
48 output_index INTEGER NOT NULL,
49 owner TEXT NOT NULL,
50 amount INTEGER NOT NULL,
51 created_at TEXT NOT NULL,
52 PRIMARY KEY (tx_id, output_index)
53 );
54 CREATE INDEX IF NOT EXISTS idx_utxos_owner ON utxos(owner);
55
56 -- Spent outputs tracking
57 CREATE TABLE IF NOT EXISTS spent (
58 tx_id TEXT NOT NULL,
59 output_index INTEGER NOT NULL,
60 spent_in_tx TEXT NOT NULL,
61 spent_at TEXT NOT NULL,
62 PRIMARY KEY (tx_id, output_index)
63 );
64
65 -- Issuance index
66 CREATE TABLE IF NOT EXISTS issuances (
67 did_hash TEXT PRIMARY KEY,
68 did TEXT NOT NULL,
69 tx_id TEXT NOT NULL,
70 amount INTEGER NOT NULL,
71 sequence INTEGER NOT NULL,
72 issued_at TEXT NOT NULL
73 );
74 CREATE INDEX IF NOT EXISTS idx_issuances_did ON issuances(did);
75
76 -- Supply tracking
77 CREATE TABLE IF NOT EXISTS supply (
78 id INTEGER PRIMARY KEY CHECK (id = 1),
79 total_issued INTEGER NOT NULL DEFAULT 0,
80 burned INTEGER NOT NULL DEFAULT 0,
81 issuance_count INTEGER NOT NULL DEFAULT 0
82 );
83 INSERT OR IGNORE INTO supply (id, total_issued, burned, issuance_count) VALUES (1, 0, 0, 0);
84
85 -- Processing cursor for firehose
86 CREATE TABLE IF NOT EXISTS cursor (
87 id INTEGER PRIMARY KEY CHECK (id = 1),
88 seq INTEGER NOT NULL DEFAULT 0
89 );
90 INSERT OR IGNORE INTO cursor (id, seq) VALUES (1, 0);
91
92 -- Cashtag rate limiting
93 CREATE TABLE IF NOT EXISTS cashtag_cooldown (
94 sender_did TEXT PRIMARY KEY,
95 last_transfer_at INTEGER NOT NULL
96 );
97
98 -- Processed posts (prevent replay)
99 CREATE TABLE IF NOT EXISTS processed_posts (
100 post_uri TEXT PRIMARY KEY,
101 processed_at TEXT NOT NULL
102 );
103
104 -- Transaction index for recent transactions
105 CREATE TABLE IF NOT EXISTS transactions (
106 tx_id TEXT PRIMARY KEY,
107 type TEXT NOT NULL,
108 total_amount INTEGER NOT NULL,
109 created_at TEXT NOT NULL
110 );
111 CREATE INDEX IF NOT EXISTS idx_transactions_created ON transactions(created_at DESC);
112 `);
113
114 this.initialized = true;
115 }
116
117 // ==========================================================================
118 // UTXO Methods
119 // ==========================================================================
120
121 async rpcAddUtxo(
122 txId: string,
123 index: number,
124 owner: string,
125 amount: number,
126 createdAt: string,
127 ): Promise<void> {
128 this.initSchema();
129 this.sql.exec(
130 `INSERT INTO utxos (tx_id, output_index, owner, amount, created_at) VALUES (?, ?, ?, ?, ?)`,
131 txId,
132 index,
133 owner,
134 amount,
135 createdAt,
136 );
137 }
138
139 async rpcSpendUtxo(
140 txId: string,
141 index: number,
142 spentInTx: string,
143 ): Promise<boolean> {
144 this.initSchema();
145
146 // Check if already spent
147 const existing = this.sql
148 .exec(
149 `SELECT 1 FROM spent WHERE tx_id = ? AND output_index = ?`,
150 txId,
151 index,
152 )
153 .toArray();
154
155 if (existing.length > 0) {
156 return false; // Already spent
157 }
158
159 // Mark as spent
160 const spentAt = new Date().toISOString();
161 this.sql.exec(
162 `INSERT INTO spent (tx_id, output_index, spent_in_tx, spent_at) VALUES (?, ?, ?, ?)`,
163 txId,
164 index,
165 spentInTx,
166 spentAt,
167 );
168
169 // Remove from UTXOs
170 this.sql.exec(
171 `DELETE FROM utxos WHERE tx_id = ? AND output_index = ?`,
172 txId,
173 index,
174 );
175
176 return true;
177 }
178
179 async rpcGetUtxo(txId: string, index: number): Promise<UTXO | null> {
180 this.initSchema();
181
182 const rows = this.sql
183 .exec(
184 `SELECT * FROM utxos WHERE tx_id = ? AND output_index = ?`,
185 txId,
186 index,
187 )
188 .toArray() as unknown as UtxoRow[];
189
190 if (rows.length === 0) {
191 return null;
192 }
193
194 const row = rows[0];
195 return {
196 $type: "cash.attoshi.utxo",
197 txId: row.tx_id,
198 index: row.output_index,
199 owner: row.owner,
200 amount: row.amount,
201 };
202 }
203
204 async rpcIsSpent(txId: string, index: number): Promise<boolean> {
205 this.initSchema();
206
207 const rows = this.sql
208 .exec(
209 `SELECT 1 FROM spent WHERE tx_id = ? AND output_index = ?`,
210 txId,
211 index,
212 )
213 .toArray();
214
215 return rows.length > 0;
216 }
217
218 async rpcGetUtxosByOwner(
219 owner: string,
220 limit: number = 50,
221 cursor?: string,
222 ): Promise<{ utxos: UTXO[]; cursor?: string }> {
223 this.initSchema();
224
225 let query = `SELECT * FROM utxos WHERE owner = ?`;
226 const params: (string | number)[] = [owner];
227
228 if (cursor) {
229 // Cursor is "txId:index"
230 const [cursorTxId, cursorIndex] = cursor.split(":");
231 query += ` AND (tx_id, output_index) > (?, ?)`;
232 params.push(cursorTxId, parseInt(cursorIndex, 10));
233 }
234
235 query += ` ORDER BY tx_id, output_index LIMIT ?`;
236 params.push(limit + 1);
237
238 const rows = this.sql
239 .exec(query, ...params)
240 .toArray() as unknown as UtxoRow[];
241
242 const hasMore = rows.length > limit;
243 const resultRows = hasMore ? rows.slice(0, limit) : rows;
244
245 const utxos = resultRows.map((row) => ({
246 $type: "cash.attoshi.utxo" as const,
247 txId: row.tx_id,
248 index: row.output_index,
249 owner: row.owner,
250 amount: row.amount,
251 }));
252
253 const nextCursor = hasMore
254 ? `${resultRows[resultRows.length - 1].tx_id}:${resultRows[resultRows.length - 1].output_index}`
255 : undefined;
256
257 return { utxos, cursor: nextCursor };
258 }
259
260 async rpcGetBalance(owner: string): Promise<number> {
261 this.initSchema();
262
263 const rows = this.sql
264 .exec(
265 `SELECT COALESCE(SUM(amount), 0) as balance FROM utxos WHERE owner = ?`,
266 owner,
267 )
268 .toArray() as unknown as Array<{ balance: number }>;
269
270 return rows[0]?.balance ?? 0;
271 }
272
273 async rpcGetUtxoCount(owner: string): Promise<number> {
274 this.initSchema();
275
276 const rows = this.sql
277 .exec(`SELECT COUNT(*) as count FROM utxos WHERE owner = ?`, owner)
278 .toArray() as unknown as Array<{ count: number }>;
279
280 return rows[0]?.count ?? 0;
281 }
282
283 // ==========================================================================
284 // Issuance Methods
285 // ==========================================================================
286
287 async rpcHasIssuance(didHash: string): Promise<boolean> {
288 this.initSchema();
289
290 const rows = this.sql
291 .exec(`SELECT 1 FROM issuances WHERE did_hash = ?`, didHash)
292 .toArray();
293
294 return rows.length > 0;
295 }
296
297 async rpcRecordIssuance(
298 didHash: string,
299 did: string,
300 txId: string,
301 amount: number,
302 ): Promise<number> {
303 this.initSchema();
304
305 // Get current sequence
306 const supplyRows = this.sql
307 .exec(`SELECT issuance_count FROM supply WHERE id = 1`)
308 .toArray() as unknown as Array<{ issuance_count: number }>;
309
310 const sequence = supplyRows[0]?.issuance_count ?? 0;
311 const issuedAt = new Date().toISOString();
312
313 // Record issuance
314 this.sql.exec(
315 `INSERT INTO issuances (did_hash, did, tx_id, amount, sequence, issued_at) VALUES (?, ?, ?, ?, ?, ?)`,
316 didHash,
317 did,
318 txId,
319 amount,
320 sequence,
321 issuedAt,
322 );
323
324 // Update supply
325 this.sql.exec(
326 `UPDATE supply SET issuance_count = issuance_count + 1, total_issued = total_issued + ? WHERE id = 1`,
327 amount,
328 );
329
330 return sequence;
331 }
332
333 async rpcGetIssuance(didHash: string): Promise<IssuanceRow | null> {
334 this.initSchema();
335
336 const rows = this.sql
337 .exec(`SELECT * FROM issuances WHERE did_hash = ?`, didHash)
338 .toArray() as unknown as IssuanceRow[];
339
340 return rows[0] ?? null;
341 }
342
343 // ==========================================================================
344 // Supply Methods
345 // ==========================================================================
346
347 async rpcGetIssuanceCount(): Promise<number> {
348 this.initSchema();
349
350 const rows = this.sql
351 .exec(`SELECT issuance_count FROM supply WHERE id = 1`)
352 .toArray() as unknown as Array<{ issuance_count: number }>;
353
354 return rows[0]?.issuance_count ?? 0;
355 }
356
357 async rpcGetSupply(): Promise<{
358 totalIssued: number;
359 burned: number;
360 issuanceCount: number;
361 }> {
362 this.initSchema();
363
364 const rows = this.sql
365 .exec(
366 `SELECT total_issued, burned, issuance_count FROM supply WHERE id = 1`,
367 )
368 .toArray() as unknown as SupplyRow[];
369
370 const row = rows[0];
371 return {
372 totalIssued: row?.total_issued ?? 0,
373 burned: row?.burned ?? 0,
374 issuanceCount: row?.issuance_count ?? 0,
375 };
376 }
377
378 async rpcRecordBurn(amount: number): Promise<void> {
379 this.initSchema();
380
381 this.sql.exec(`UPDATE supply SET burned = burned + ? WHERE id = 1`, amount);
382 }
383
384 async rpcAddToTotalIssued(amount: number): Promise<void> {
385 this.initSchema();
386
387 this.sql.exec(
388 `UPDATE supply SET total_issued = total_issued + ? WHERE id = 1`,
389 amount,
390 );
391 }
392
393 // ==========================================================================
394 // Cursor Methods (for firehose)
395 // ==========================================================================
396
397 async rpcGetCursor(): Promise<number> {
398 this.initSchema();
399
400 const rows = this.sql
401 .exec(`SELECT seq FROM cursor WHERE id = 1`)
402 .toArray() as unknown as Array<{ seq: number }>;
403
404 return rows[0]?.seq ?? 0;
405 }
406
407 async rpcSetCursor(seq: number): Promise<void> {
408 this.initSchema();
409
410 this.sql.exec(`UPDATE cursor SET seq = ? WHERE id = 1`, seq);
411 }
412
413 // ==========================================================================
414 // Batch Operations
415 // ==========================================================================
416
417 async rpcProcessTransfer(
418 txId: string,
419 inputs: Array<{ txId: string; index: number }>,
420 outputs: TransactionOutput[],
421 createdAt: string,
422 transferAmount?: number, // Actual amount transferred (for display)
423 ): Promise<{ burned: number }> {
424 this.initSchema();
425
426 let inputSum = 0;
427 let outputSum = 0;
428
429 // Mark inputs as spent
430 for (const input of inputs) {
431 const utxo = await this.rpcGetUtxo(input.txId, input.index);
432 if (utxo) {
433 inputSum += utxo.amount;
434 await this.rpcSpendUtxo(input.txId, input.index, txId);
435 }
436 }
437
438 // Add new outputs
439 for (let i = 0; i < outputs.length; i++) {
440 const output = outputs[i];
441 outputSum += output.amount;
442 await this.rpcAddUtxo(txId, i, output.owner, output.amount, createdAt);
443 }
444
445 // Record burn if any
446 const burned = inputSum - outputSum;
447 if (burned > 0) {
448 await this.rpcRecordBurn(burned);
449 }
450
451 // Record transaction in index (use transferAmount if provided, else outputSum)
452 const displayAmount = transferAmount ?? outputSum;
453 await this.rpcRecordTransaction(txId, "transfer", displayAmount, createdAt);
454
455 return { burned };
456 }
457
458 async rpcProcessIssuance(
459 txId: string,
460 outputs: TransactionOutput[],
461 didHash: string,
462 did: string,
463 createdAt: string,
464 ): Promise<{ sequence: number }> {
465 this.initSchema();
466
467 // Add outputs
468 let totalAmount = 0;
469 for (let i = 0; i < outputs.length; i++) {
470 const output = outputs[i];
471 totalAmount += output.amount;
472 await this.rpcAddUtxo(txId, i, output.owner, output.amount, createdAt);
473 }
474
475 // Record issuance (user amount is first output)
476 const sequence = await this.rpcRecordIssuance(
477 didHash,
478 did,
479 txId,
480 totalAmount,
481 );
482
483 // Record transaction in index (use user reward - first output - for display)
484 const userReward = outputs[0]?.amount ?? totalAmount;
485 await this.rpcRecordTransaction(txId, "issuance", userReward, createdAt);
486
487 return { sequence };
488 }
489
490 // ==========================================================================
491 // Cashtag Rate Limiting
492 // ==========================================================================
493
494 async rpcCheckCashtagCooldown(senderDid: string, cooldownSeconds: number): Promise<boolean> {
495 this.initSchema();
496
497 const now = Date.now();
498 const rows = this.sql
499 .exec(`SELECT last_transfer_at FROM cashtag_cooldown WHERE sender_did = ?`, senderDid)
500 .toArray() as unknown as Array<{ last_transfer_at: number }>;
501
502 if (rows.length === 0) {
503 return true; // No previous transfer, allowed
504 }
505
506 const lastTransfer = rows[0].last_transfer_at;
507 const elapsed = (now - lastTransfer) / 1000;
508
509 return elapsed >= cooldownSeconds;
510 }
511
512 async rpcRecordCashtagTransfer(senderDid: string): Promise<void> {
513 this.initSchema();
514
515 const now = Date.now();
516 this.sql.exec(
517 `INSERT OR REPLACE INTO cashtag_cooldown (sender_did, last_transfer_at) VALUES (?, ?)`,
518 senderDid,
519 now,
520 );
521 }
522
523 async rpcIsPostProcessed(postUri: string): Promise<boolean> {
524 this.initSchema();
525
526 const rows = this.sql
527 .exec(`SELECT 1 FROM processed_posts WHERE post_uri = ?`, postUri)
528 .toArray();
529
530 return rows.length > 0;
531 }
532
533 async rpcMarkPostProcessed(postUri: string): Promise<void> {
534 this.initSchema();
535
536 const processedAt = new Date().toISOString();
537 this.sql.exec(
538 `INSERT OR IGNORE INTO processed_posts (post_uri, processed_at) VALUES (?, ?)`,
539 postUri,
540 processedAt,
541 );
542 }
543
544 // ==========================================================================
545 // Transaction Index Methods
546 // ==========================================================================
547
548 async rpcRecordTransaction(
549 txId: string,
550 type: "issuance" | "transfer",
551 totalAmount: number,
552 createdAt: string,
553 ): Promise<void> {
554 this.initSchema();
555 this.sql.exec(
556 `INSERT OR REPLACE INTO transactions (tx_id, type, total_amount, created_at) VALUES (?, ?, ?, ?)`,
557 txId,
558 type,
559 totalAmount,
560 createdAt,
561 );
562 }
563
564 async rpcGetRecentTransactions(limit: number = 20): Promise<
565 Array<{ txId: string; type: string; totalAmount: number; createdAt: string }>
566 > {
567 this.initSchema();
568
569 const rows = this.sql
570 .exec(
571 `SELECT tx_id, type, total_amount, created_at FROM transactions ORDER BY created_at DESC LIMIT ?`,
572 limit,
573 )
574 .toArray() as unknown as Array<{
575 tx_id: string;
576 type: string;
577 total_amount: number;
578 created_at: string;
579 }>;
580
581 return rows.map((row) => ({
582 txId: row.tx_id,
583 type: row.type,
584 totalAmount: row.total_amount,
585 createdAt: row.created_at,
586 }));
587 }
588
589 // ==========================================================================
590 // Debug / Admin
591 // ==========================================================================
592
593 async rpcClearAll(): Promise<void> {
594 this.initSchema();
595
596 this.sql.exec(`DELETE FROM utxos`);
597 this.sql.exec(`DELETE FROM spent`);
598 this.sql.exec(`DELETE FROM issuances`);
599 this.sql.exec(`DELETE FROM cashtag_cooldown`);
600 this.sql.exec(`DELETE FROM processed_posts`);
601 this.sql.exec(`DELETE FROM transactions`);
602 this.sql.exec(
603 `UPDATE supply SET total_issued = 0, burned = 0, issuance_count = 0 WHERE id = 1`,
604 );
605 this.sql.exec(`UPDATE cursor SET seq = 0 WHERE id = 1`);
606 }
607
608 async rpcGetStats(): Promise<{
609 utxoCount: number;
610 spentCount: number;
611 issuanceCount: number;
612 supply: SupplyRow;
613 }> {
614 this.initSchema();
615
616 const utxoCount =
617 (
618 this.sql
619 .exec(`SELECT COUNT(*) as c FROM utxos`)
620 .toArray() as unknown as Array<{ c: number }>
621 )[0]?.c ?? 0;
622
623 const spentCount =
624 (
625 this.sql
626 .exec(`SELECT COUNT(*) as c FROM spent`)
627 .toArray() as unknown as Array<{ c: number }>
628 )[0]?.c ?? 0;
629
630 const issuanceCount =
631 (
632 this.sql
633 .exec(`SELECT COUNT(*) as c FROM issuances`)
634 .toArray() as unknown as Array<{ c: number }>
635 )[0]?.c ?? 0;
636
637 const supply = (
638 this.sql
639 .exec(`SELECT * FROM supply WHERE id = 1`)
640 .toArray() as unknown as SupplyRow[]
641 )[0] ?? { total_issued: 0, burned: 0, issuance_count: 0 };
642
643 return { utxoCount, spentCount, issuanceCount, supply };
644 }
645}