this repo has no description
at main 263 lines 7.7 kB view raw
1/** 2 * Attoshi PDS API client 3 */ 4 5import type { OAuthSession } from '@atproto/oauth-client-browser'; 6 7const PDS_URL = 'https://pds.attoshi.com'; 8 9/** 10 * Number of decimal places for toshi amounts 11 * Amounts from API are in micro-toshis (smallest unit) 12 */ 13const DECIMALS = 6; 14const MICRO = Math.pow(10, DECIMALS); 15 16/** 17 * Format an amount from micro-toshis to display string 18 * e.g., 100000000000 -> "100,000" 19 * e.g., 1500000 -> "1.5" 20 */ 21export function formatToshis(microToshis: number): string { 22 const toshis = microToshis / MICRO; 23 if (toshis % 1 === 0) { 24 return toshis.toLocaleString(); 25 } 26 return toshis.toLocaleString(undefined, { 27 minimumFractionDigits: 0, 28 maximumFractionDigits: DECIMALS, 29 }); 30} 31 32/** 33 * Parse a display amount to micro-toshis 34 * e.g., "100,000" -> 100000000000 35 * e.g., "1.5" -> 1500000 36 */ 37export function parseToshis(displayAmount: string): number { 38 const cleaned = displayAmount.replace(/,/g, ''); 39 const toshis = parseFloat(cleaned); 40 if (isNaN(toshis)) { 41 throw new Error('Invalid amount'); 42 } 43 return Math.round(toshis * MICRO); 44} 45 46export { DECIMALS, MICRO }; 47 48export interface Config { 49 name: string; 50 entity: string; 51 treasury: string; 52 policy: { 53 userAmount: number; 54 treasuryAmount: number; 55 halvingInterval: number; 56 maxIssuances: number; 57 }; 58} 59 60export interface Supply { 61 totalIssued: number; 62 circulatingSupply: number; 63 burned: number; 64 issuanceCount: number; 65 currentReward: { user: number; treasury: number }; 66 nextHalvingAt: number; 67} 68 69export interface Balance { 70 did: string; 71 balance: number; 72 utxoCount: number; 73} 74 75export interface UTXO { 76 txId: string; 77 index: number; 78 owner: string; 79 amount: number; 80} 81 82export interface Transaction { 83 id?: string; 84 type: 'issuance' | 'transfer'; 85 inputs: Array<{ txId: string; index: number; sig?: string }>; 86 outputs: Array<{ owner: string; amount: number }>; 87 trigger?: 88 | { followUri: string; followerDid: string } // Issuance trigger (follow) 89 | { type: 'cashtag'; postUri: string; senderDid: string } // Cashtag transfer 90 | null; 91 createdAt: string; 92} 93 94export interface TransactionResult { 95 txId: string; 96 uri: string; 97 commit: { cid: string; rev: string }; 98} 99 100async function xrpc<T>(method: string, params?: Record<string, string | number>): Promise<T> { 101 const url = new URL(`${PDS_URL}/xrpc/${method}`); 102 if (params) { 103 for (const [key, value] of Object.entries(params)) { 104 url.searchParams.set(key, String(value)); 105 } 106 } 107 const res = await fetch(url.toString()); 108 if (!res.ok) { 109 const error = await res.json().catch(() => ({ error: 'Unknown error' })); 110 throw new Error(error.error || `HTTP ${res.status}`); 111 } 112 return res.json(); 113} 114 115async function xrpcPost<T>(method: string, body: unknown, authToken?: string): Promise<T> { 116 const headers: Record<string, string> = { 'Content-Type': 'application/json' }; 117 if (authToken) { 118 headers['Authorization'] = `Bearer ${authToken}`; 119 } 120 const res = await fetch(`${PDS_URL}/xrpc/${method}`, { 121 method: 'POST', 122 headers, 123 body: JSON.stringify(body), 124 }); 125 if (!res.ok) { 126 const error = await res.json().catch(() => ({ error: 'Unknown error' })); 127 throw new Error(error.message || error.error || `HTTP ${res.status}`); 128 } 129 return res.json(); 130} 131 132export async function getConfig(): Promise<{ config: Config }> { 133 return xrpc('cash.attoshi.getConfig'); 134} 135 136export async function getSupply(): Promise<Supply> { 137 return xrpc('cash.attoshi.getSupply'); 138} 139 140export async function getBalance(did: string): Promise<Balance> { 141 return xrpc('cash.attoshi.getBalance', { did }); 142} 143 144export async function getUtxos(did: string, limit = 50, cursor?: string): Promise<{ utxos: UTXO[]; cursor?: string }> { 145 const params: Record<string, string | number> = { did, limit }; 146 if (cursor) params.cursor = cursor; 147 return xrpc('cash.attoshi.getUtxos', params); 148} 149 150export async function getTransaction(txId: string): Promise<{ transaction: Transaction }> { 151 return xrpc('cash.attoshi.getTransaction', { txId }); 152} 153 154export interface RecentTransaction { 155 txId: string; 156 type: string; 157 totalAmount: number; 158 createdAt: string; 159} 160 161export async function getRecentTransactions(limit = 20): Promise<{ transactions: RecentTransaction[] }> { 162 return xrpc('cash.attoshi.getRecentTransactions', { limit }); 163} 164 165export async function submitTransaction( 166 inputs: Array<{ txId: string; index: number; sig?: string }>, 167 outputs: Array<{ owner: string; amount: number }>, 168 authToken?: string 169): Promise<TransactionResult> { 170 return xrpcPost('cash.attoshi.submitTransaction', { inputs, outputs }, authToken); 171} 172 173/** 174 * Submit a transaction using OAuth session authentication 175 * Uses the session's fetchHandler which properly handles DPoP tokens 176 * senderDid is passed in body since AT Protocol uses opaque tokens 177 */ 178export async function submitTransactionWithSession( 179 session: OAuthSession, 180 inputs: Array<{ txId: string; index: number }>, 181 outputs: Array<{ owner: string; amount: number }> 182): Promise<TransactionResult> { 183 const url = `${PDS_URL}/xrpc/cash.attoshi.submitTransaction`; 184 185 // Include senderDid in body - AT Protocol uses opaque access tokens 186 // so the server can't extract the DID from the token 187 const res = await session.fetchHandler(url, { 188 method: 'POST', 189 headers: { 'Content-Type': 'application/json' }, 190 body: JSON.stringify({ inputs, outputs, senderDid: session.did }), 191 }); 192 193 if (!res.ok) { 194 const error = await res.json().catch(() => ({ error: 'Unknown error' })); 195 throw new Error(error.message || error.error || `HTTP ${res.status}`); 196 } 197 198 return res.json(); 199} 200 201// Firehose status 202export interface FirehoseStatus { 203 connected: boolean; 204 reconnectAttempts: number; 205 entityDid: string; 206} 207 208export async function getFirehoseStatus(): Promise<FirehoseStatus> { 209 return xrpc('cash.attoshi.getFirehoseStatus'); 210} 211 212/** 213 * Resolve a handle to a DID using bsky.social's resolver 214 * Handles can be with or without @ prefix 215 */ 216export async function resolveHandle(handle: string): Promise<string> { 217 // Remove @ prefix if present 218 const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; 219 220 const url = `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(cleanHandle)}`; 221 const res = await fetch(url); 222 223 if (!res.ok) { 224 const error = await res.json().catch(() => ({ error: 'Unknown error' })); 225 throw new Error(error.message || error.error || `Failed to resolve handle: ${cleanHandle}`); 226 } 227 228 const data = await res.json(); 229 return data.did; 230} 231 232// Standard.site document format 233export interface StandardDocument { 234 $type: 'site.standard.document'; 235 site: string; // AT-URI or https URL to publication 236 path?: string; // Path to append to site URL 237 title: string; 238 description?: string; 239 coverImage?: unknown; // blob 240 content?: unknown; // union - content format 241 textContent?: string; // plaintext content 242 bskyPostRef?: { uri: string; cid: string }; 243 tags?: string[]; 244 publishedAt: string; 245 updatedAt?: string; 246} 247 248export interface StandardDocumentRecord { 249 uri: string; 250 cid: string; 251 value: StandardDocument; 252} 253 254export async function getDocument(rkey: string): Promise<StandardDocumentRecord> { 255 const did = 'did:web:pds.attoshi.com'; 256 const url = `${PDS_URL}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=site.standard.document&rkey=${encodeURIComponent(rkey)}`; 257 const res = await fetch(url); 258 if (!res.ok) { 259 const error = await res.json().catch(() => ({ error: 'Unknown error' })); 260 throw new Error(error.error || `HTTP ${res.status}`); 261 } 262 return res.json(); 263}