this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 645 lines 17 kB view raw
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}