/** * Attoshi PDS API client */ import type { OAuthSession } from '@atproto/oauth-client-browser'; const PDS_URL = 'https://pds.attoshi.com'; /** * Number of decimal places for toshi amounts * Amounts from API are in micro-toshis (smallest unit) */ const DECIMALS = 6; const MICRO = Math.pow(10, DECIMALS); /** * Format an amount from micro-toshis to display string * e.g., 100000000000 -> "100,000" * e.g., 1500000 -> "1.5" */ export function formatToshis(microToshis: number): string { const toshis = microToshis / MICRO; if (toshis % 1 === 0) { return toshis.toLocaleString(); } return toshis.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: DECIMALS, }); } /** * Parse a display amount to micro-toshis * e.g., "100,000" -> 100000000000 * e.g., "1.5" -> 1500000 */ export function parseToshis(displayAmount: string): number { const cleaned = displayAmount.replace(/,/g, ''); const toshis = parseFloat(cleaned); if (isNaN(toshis)) { throw new Error('Invalid amount'); } return Math.round(toshis * MICRO); } export { DECIMALS, MICRO }; export interface Config { name: string; entity: string; treasury: string; policy: { userAmount: number; treasuryAmount: number; halvingInterval: number; maxIssuances: number; }; } export interface Supply { totalIssued: number; circulatingSupply: number; burned: number; issuanceCount: number; currentReward: { user: number; treasury: number }; nextHalvingAt: number; } export interface Balance { did: string; balance: number; utxoCount: number; } export interface UTXO { txId: string; index: number; owner: string; amount: number; } export interface Transaction { id?: string; type: 'issuance' | 'transfer'; inputs: Array<{ txId: string; index: number; sig?: string }>; outputs: Array<{ owner: string; amount: number }>; trigger?: | { followUri: string; followerDid: string } // Issuance trigger (follow) | { type: 'cashtag'; postUri: string; senderDid: string } // Cashtag transfer | null; createdAt: string; } export interface TransactionResult { txId: string; uri: string; commit: { cid: string; rev: string }; } async function xrpc(method: string, params?: Record): Promise { const url = new URL(`${PDS_URL}/xrpc/${method}`); if (params) { for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, String(value)); } } const res = await fetch(url.toString()); if (!res.ok) { const error = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(error.error || `HTTP ${res.status}`); } return res.json(); } async function xrpcPost(method: string, body: unknown, authToken?: string): Promise { const headers: Record = { 'Content-Type': 'application/json' }; if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } const res = await fetch(`${PDS_URL}/xrpc/${method}`, { method: 'POST', headers, body: JSON.stringify(body), }); if (!res.ok) { const error = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(error.message || error.error || `HTTP ${res.status}`); } return res.json(); } export async function getConfig(): Promise<{ config: Config }> { return xrpc('cash.attoshi.getConfig'); } export async function getSupply(): Promise { return xrpc('cash.attoshi.getSupply'); } export async function getBalance(did: string): Promise { return xrpc('cash.attoshi.getBalance', { did }); } export async function getUtxos(did: string, limit = 50, cursor?: string): Promise<{ utxos: UTXO[]; cursor?: string }> { const params: Record = { did, limit }; if (cursor) params.cursor = cursor; return xrpc('cash.attoshi.getUtxos', params); } export async function getTransaction(txId: string): Promise<{ transaction: Transaction }> { return xrpc('cash.attoshi.getTransaction', { txId }); } export interface RecentTransaction { txId: string; type: string; totalAmount: number; createdAt: string; } export async function getRecentTransactions(limit = 20): Promise<{ transactions: RecentTransaction[] }> { return xrpc('cash.attoshi.getRecentTransactions', { limit }); } export async function submitTransaction( inputs: Array<{ txId: string; index: number; sig?: string }>, outputs: Array<{ owner: string; amount: number }>, authToken?: string ): Promise { return xrpcPost('cash.attoshi.submitTransaction', { inputs, outputs }, authToken); } /** * Submit a transaction using OAuth session authentication * Uses the session's fetchHandler which properly handles DPoP tokens * senderDid is passed in body since AT Protocol uses opaque tokens */ export async function submitTransactionWithSession( session: OAuthSession, inputs: Array<{ txId: string; index: number }>, outputs: Array<{ owner: string; amount: number }> ): Promise { const url = `${PDS_URL}/xrpc/cash.attoshi.submitTransaction`; // Include senderDid in body - AT Protocol uses opaque access tokens // so the server can't extract the DID from the token const res = await session.fetchHandler(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inputs, outputs, senderDid: session.did }), }); if (!res.ok) { const error = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(error.message || error.error || `HTTP ${res.status}`); } return res.json(); } // Firehose status export interface FirehoseStatus { connected: boolean; reconnectAttempts: number; entityDid: string; } export async function getFirehoseStatus(): Promise { return xrpc('cash.attoshi.getFirehoseStatus'); } /** * Resolve a handle to a DID using bsky.social's resolver * Handles can be with or without @ prefix */ export async function resolveHandle(handle: string): Promise { // Remove @ prefix if present const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; const url = `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(cleanHandle)}`; const res = await fetch(url); if (!res.ok) { const error = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(error.message || error.error || `Failed to resolve handle: ${cleanHandle}`); } const data = await res.json(); return data.did; } // Standard.site document format export interface StandardDocument { $type: 'site.standard.document'; site: string; // AT-URI or https URL to publication path?: string; // Path to append to site URL title: string; description?: string; coverImage?: unknown; // blob content?: unknown; // union - content format textContent?: string; // plaintext content bskyPostRef?: { uri: string; cid: string }; tags?: string[]; publishedAt: string; updatedAt?: string; } export interface StandardDocumentRecord { uri: string; cid: string; value: StandardDocument; } export async function getDocument(rkey: string): Promise { const did = 'did:web:pds.attoshi.com'; const url = `${PDS_URL}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=site.standard.document&rkey=${encodeURIComponent(rkey)}`; const res = await fetch(url); if (!res.ok) { const error = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(error.error || `HTTP ${res.status}`); } return res.json(); }