this repo has no description
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}