@thru/abi 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,482 @@
1
+ /**
2
+ * On-chain ABI Fetcher
3
+ *
4
+ * Fetches ABI content from on-chain accounts.
5
+ */
6
+
7
+ import type { AbiAccountData, RpcEndpoints, RevisionSpec, OnchainTarget } from "./types";
8
+ import {
9
+ ABI_ACCOUNT_HEADER_SIZE,
10
+ ABI_STATE_OPEN,
11
+ ABI_STATE_FINALIZED,
12
+ DEFAULT_RPC_ENDPOINTS,
13
+ } from "./types";
14
+
15
+ /**
16
+ * Minimal interface for a Thru RPC client that can fetch raw accounts.
17
+ * This is compatible with @thru/thru-sdk client but doesn't require it.
18
+ */
19
+ export interface ThruRpcClient {
20
+ query: {
21
+ getRawAccount: (request: {
22
+ address: { value: Uint8Array };
23
+ versionContext: Record<string, unknown>;
24
+ }) => Promise<{ rawData?: Uint8Array }>;
25
+ };
26
+ }
27
+
28
+ /* ABI account seed suffix (matches on-chain ABI manager) */
29
+ const ABI_ACCOUNT_SUFFIX = "_abi_account";
30
+ const ABI_ACCOUNT_SUFFIX_BYTES = new TextEncoder().encode(ABI_ACCOUNT_SUFFIX);
31
+
32
+ const ABI_META_HEADER_SIZE = 4;
33
+ const ABI_META_BODY_SIZE = 96;
34
+ const ABI_META_ACCOUNT_SIZE = ABI_META_HEADER_SIZE + ABI_META_BODY_SIZE;
35
+
36
+ const ABI_META_VERSION = 1;
37
+ const ABI_META_KIND_OFFICIAL = 0;
38
+ const ABI_META_KIND_EXTERNAL = 1;
39
+
40
+ const DEFAULT_ABI_MANAGER_PROGRAM_ID =
41
+ "taWqAAOSe9pavaaMpkc9VbSLBUMbuW6Mk59sZlSbcNHsJA";
42
+
43
+ async function sha256Bytes(data: Uint8Array): Promise<Uint8Array> {
44
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data as BufferSource);
45
+ return new Uint8Array(hashBuffer);
46
+ }
47
+
48
+ function concatBytes(...chunks: Uint8Array[]): Uint8Array {
49
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
50
+ const out = new Uint8Array(total);
51
+ let offset = 0;
52
+ for (const chunk of chunks) {
53
+ out.set(chunk, offset);
54
+ offset += chunk.length;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function abiMetaBodyForProgram(program: Uint8Array): Uint8Array {
60
+ const body = new Uint8Array(ABI_META_BODY_SIZE);
61
+ body.set(program.slice(0, 32), 0);
62
+ return body;
63
+ }
64
+
65
+ async function deriveAbiAccountSeed(kind: number, body: Uint8Array): Promise<Uint8Array> {
66
+ return sha256Bytes(concatBytes(new Uint8Array([kind]), body, ABI_ACCOUNT_SUFFIX_BYTES));
67
+ }
68
+
69
+ async function createProgramDefinedAccountAddress(
70
+ owner: Uint8Array,
71
+ isEphemeral: boolean,
72
+ seed: Uint8Array
73
+ ): Promise<Uint8Array> {
74
+ const flag = new Uint8Array([isEphemeral ? 1 : 0]);
75
+ return sha256Bytes(concatBytes(owner, flag, seed));
76
+ }
77
+
78
+ async function deriveAbiAccountAddress(
79
+ kind: number,
80
+ body: Uint8Array,
81
+ owner: Uint8Array,
82
+ isEphemeral: boolean
83
+ ): Promise<Uint8Array> {
84
+ const seed = await deriveAbiAccountSeed(kind, body);
85
+ return createProgramDefinedAccountAddress(owner, isEphemeral, seed);
86
+ }
87
+
88
+ /**
89
+ * Derive the official ABI account address for a given program.
90
+ *
91
+ * This performs the same derivation as OnchainFetcher.fetch() with target="program",
92
+ * returning the raw 32-byte address of the ABI account.
93
+ */
94
+ export async function deriveOfficialAbiAddress(
95
+ programAddress: string,
96
+ abiManagerProgramId: string = DEFAULT_ABI_MANAGER_PROGRAM_ID
97
+ ): Promise<Uint8Array> {
98
+ const programBytes = decodeAddress(programAddress);
99
+ const managerBytes = decodeAddress(abiManagerProgramId);
100
+ const body = abiMetaBodyForProgram(programBytes);
101
+ return deriveAbiAccountAddress(ABI_META_KIND_OFFICIAL, body, managerBytes, false);
102
+ }
103
+
104
+ type AbiMetaAccount = {
105
+ version: number;
106
+ kind: number;
107
+ flags: number;
108
+ body: Uint8Array;
109
+ };
110
+
111
+ function parseAbiMetaAccount(data: Uint8Array): AbiMetaAccount {
112
+ if (data.length < ABI_META_ACCOUNT_SIZE) {
113
+ throw new Error(
114
+ `ABI meta account data too short: ${data.length} bytes, expected at least ${ABI_META_ACCOUNT_SIZE}`
115
+ );
116
+ }
117
+
118
+ const version = data[0];
119
+ const kind = data[1];
120
+ const flags = data[2] | (data[3] << 8);
121
+ const body = data.slice(ABI_META_HEADER_SIZE, ABI_META_HEADER_SIZE + ABI_META_BODY_SIZE);
122
+
123
+ if (version !== ABI_META_VERSION) {
124
+ throw new Error(`Unsupported ABI meta version: ${version}`);
125
+ }
126
+ if (kind !== ABI_META_KIND_OFFICIAL && kind !== ABI_META_KIND_EXTERNAL) {
127
+ throw new Error(`Unsupported ABI meta kind: ${kind}`);
128
+ }
129
+
130
+ return { version, kind, flags, body };
131
+ }
132
+
133
+ /**
134
+ * Parse ABI account data from raw bytes.
135
+ *
136
+ * Account header layout (45 bytes):
137
+ * - abi_meta_account: [u8; 32]
138
+ * - revision: u64 (little-endian)
139
+ * - state: u8 (0x00=OPEN, 0x01=FINALIZED)
140
+ * - content_sz: u32 (little-endian)
141
+ * - content: [u8; content_sz]
142
+ */
143
+ export function parseAbiAccountData(data: Uint8Array): AbiAccountData {
144
+ if (data.length < ABI_ACCOUNT_HEADER_SIZE) {
145
+ throw new Error(
146
+ `ABI account data too short: ${data.length} bytes, expected at least ${ABI_ACCOUNT_HEADER_SIZE}`
147
+ );
148
+ }
149
+
150
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
151
+
152
+ const abiMetaAccount = data.slice(0, 32);
153
+ const revision = view.getBigUint64(32, true);
154
+ const state = data[40];
155
+ const contentSize = view.getUint32(41, true);
156
+
157
+ if (state !== ABI_STATE_OPEN && state !== ABI_STATE_FINALIZED) {
158
+ throw new Error(`Invalid ABI account state: ${state}`);
159
+ }
160
+
161
+ const expectedSize = ABI_ACCOUNT_HEADER_SIZE + contentSize;
162
+ if (data.length < expectedSize) {
163
+ throw new Error(
164
+ `ABI account data truncated: ${data.length} bytes, expected ${expectedSize}`
165
+ );
166
+ }
167
+
168
+ const contentBytes = data.slice(ABI_ACCOUNT_HEADER_SIZE, expectedSize);
169
+ const content = new TextDecoder().decode(contentBytes);
170
+
171
+ return {
172
+ abiMetaAccount,
173
+ revision,
174
+ state,
175
+ content,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Check if a revision matches the specification.
181
+ */
182
+ export function revisionMatches(revision: bigint, spec: RevisionSpec): boolean {
183
+ switch (spec.type) {
184
+ case "latest":
185
+ return true;
186
+ case "exact":
187
+ return revision === BigInt(spec.value);
188
+ case "minimum":
189
+ return revision >= BigInt(spec.value);
190
+ default:
191
+ return false;
192
+ }
193
+ }
194
+
195
+ export interface OnchainFetcherConfig {
196
+ rpcEndpoints?: RpcEndpoints;
197
+ thruClient?: ThruRpcClient;
198
+ abiManagerProgramId?: string;
199
+ abiManagerIsEphemeral?: boolean;
200
+ }
201
+
202
+ export interface FetchResult {
203
+ abiYaml: string;
204
+ revision: bigint;
205
+ isFinalized: boolean;
206
+ }
207
+
208
+ /**
209
+ * Fetcher for on-chain ABI accounts.
210
+ *
211
+ * Supports fetching ABI from:
212
+ * - Official ABI via program (target: "program")
213
+ * - ABI via ABI meta account (target: "abi-meta")
214
+ * - Direct ABI account (target: "abi")
215
+ */
216
+ export class OnchainFetcher {
217
+ private rpcEndpoints: RpcEndpoints;
218
+ private thruClient?: ThruRpcClient;
219
+ private abiManagerProgramId: Uint8Array;
220
+ private abiManagerIsEphemeral: boolean;
221
+
222
+ constructor(config: OnchainFetcherConfig = {}) {
223
+ this.rpcEndpoints = { ...DEFAULT_RPC_ENDPOINTS, ...config.rpcEndpoints };
224
+ this.thruClient = config.thruClient;
225
+ const managerId = config.abiManagerProgramId ?? DEFAULT_ABI_MANAGER_PROGRAM_ID;
226
+ this.abiManagerProgramId = decodeAddress(managerId);
227
+ this.abiManagerIsEphemeral = config.abiManagerIsEphemeral ?? false;
228
+ }
229
+
230
+ /**
231
+ * Fetch ABI content from an on-chain account.
232
+ */
233
+ async fetch(
234
+ address: string,
235
+ target: OnchainTarget,
236
+ network: string,
237
+ revision: RevisionSpec
238
+ ): Promise<FetchResult> {
239
+ const addressBytes = this.parseAddress(address);
240
+ let abiAddress: Uint8Array;
241
+ if (target === "program") {
242
+ const body = abiMetaBodyForProgram(addressBytes);
243
+ abiAddress = await deriveAbiAccountAddress(
244
+ ABI_META_KIND_OFFICIAL,
245
+ body,
246
+ this.abiManagerProgramId,
247
+ this.abiManagerIsEphemeral
248
+ );
249
+ } else if (target === "abi-meta") {
250
+ const metaData = await this.fetchAccountData(addressBytes, network);
251
+ const meta = parseAbiMetaAccount(metaData);
252
+ abiAddress = await deriveAbiAccountAddress(
253
+ meta.kind,
254
+ meta.body,
255
+ this.abiManagerProgramId,
256
+ this.abiManagerIsEphemeral
257
+ );
258
+ } else {
259
+ abiAddress = addressBytes;
260
+ }
261
+
262
+ const accountData = await this.fetchAccountData(abiAddress, network);
263
+ const parsed = parseAbiAccountData(accountData);
264
+
265
+ if (!revisionMatches(parsed.revision, revision)) {
266
+ const revisionStr =
267
+ revision.type === "exact"
268
+ ? `exactly ${revision.value}`
269
+ : revision.type === "minimum"
270
+ ? `at least ${revision.value}`
271
+ : "latest";
272
+ throw new Error(
273
+ `ABI revision mismatch: got ${parsed.revision}, expected ${revisionStr}`
274
+ );
275
+ }
276
+
277
+ return {
278
+ abiYaml: parsed.content,
279
+ revision: parsed.revision,
280
+ isFinalized: parsed.state === ABI_STATE_FINALIZED,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Get the RPC endpoint for a network.
286
+ */
287
+ getRpcEndpoint(network: string): string {
288
+ const endpoint = this.rpcEndpoints[network];
289
+ if (!endpoint) {
290
+ throw new Error(
291
+ `Unknown network: ${network}. Configure rpcEndpoints for this network.`
292
+ );
293
+ }
294
+ return endpoint;
295
+ }
296
+
297
+ private parseAddress(address: string): Uint8Array {
298
+ return decodeAddress(address);
299
+ }
300
+
301
+ private async fetchAccountData(address: Uint8Array, network: string): Promise<Uint8Array> {
302
+ if (this.thruClient) {
303
+ return this.fetchWithThruClient(address);
304
+ }
305
+ return this.fetchWithHttp(address, network);
306
+ }
307
+
308
+ private async fetchWithThruClient(address: Uint8Array): Promise<Uint8Array> {
309
+ if (!this.thruClient) {
310
+ throw new Error("ThruClient not configured");
311
+ }
312
+
313
+ const response = await this.thruClient.query.getRawAccount({
314
+ address: { value: address },
315
+ versionContext: {},
316
+ });
317
+
318
+ if (!response.rawData) {
319
+ throw new Error("Account not found or has no data");
320
+ }
321
+
322
+ return response.rawData;
323
+ }
324
+
325
+ private async fetchWithHttp(address: Uint8Array, network: string): Promise<Uint8Array> {
326
+ const endpoint = this.getRpcEndpoint(network);
327
+ const addressStr = encodeThruAddress(address);
328
+
329
+ /* Use HTTP/JSON-RPC fallback */
330
+ const response = await fetch(`${endpoint}/v1/accounts/${addressStr}:raw`, {
331
+ method: "GET",
332
+ headers: {
333
+ Accept: "application/json",
334
+ },
335
+ });
336
+
337
+ if (!response.ok) {
338
+ if (response.status === 404) {
339
+ throw new Error(`ABI account not found: ${addressStr}`);
340
+ }
341
+ throw new Error(`Failed to fetch account: ${response.status} ${response.statusText}`);
342
+ }
343
+
344
+ const json = await response.json();
345
+ if (!json.rawData) {
346
+ throw new Error("Account has no data");
347
+ }
348
+
349
+ /* rawData is base64 encoded */
350
+ return base64Decode(json.rawData);
351
+ }
352
+ }
353
+
354
+ const BASE64_URL_ALPHABET =
355
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
356
+
357
+ function decodeAddress(address: string): Uint8Array {
358
+ if (address.startsWith("ta") && address.length === 46) {
359
+ return decodeThruAddress(address);
360
+ }
361
+ throw new Error(`Invalid Thru address format: ${address} (expected 46-char ta-prefixed address)`);
362
+ }
363
+
364
+ function decodeThruAddress(address: string): Uint8Array {
365
+ if (address.length !== 46 || !address.startsWith("ta")) {
366
+ throw new Error(`Invalid Thru address: ${address}`);
367
+ }
368
+
369
+ const invlut = new Int16Array(256);
370
+ invlut.fill(-1);
371
+ for (let i = 0; i < BASE64_URL_ALPHABET.length; i += 1) {
372
+ invlut[BASE64_URL_ALPHABET.charCodeAt(i)] = i;
373
+ }
374
+
375
+ let inIdx = 2;
376
+ let inSz = 40;
377
+ let outIdx = 0;
378
+ let checksum = 0;
379
+ const out = new Uint8Array(32);
380
+
381
+ while (inSz >= 4) {
382
+ const a = invlut[address.charCodeAt(inIdx + 0)];
383
+ const b = invlut[address.charCodeAt(inIdx + 1)];
384
+ const c = invlut[address.charCodeAt(inIdx + 2)];
385
+ const d = invlut[address.charCodeAt(inIdx + 3)];
386
+ if (a < 0 || b < 0 || c < 0 || d < 0) {
387
+ throw new Error(`Invalid Thru address character at ${inIdx}`);
388
+ }
389
+ const triple = (a << 18) | (b << 12) | (c << 6) | d;
390
+ const temp1 = (triple >> 16) & 0xff;
391
+ checksum += temp1;
392
+ out[outIdx++] = temp1;
393
+ const temp2 = (triple >> 8) & 0xff;
394
+ checksum += temp2;
395
+ out[outIdx++] = temp2;
396
+ const temp3 = triple & 0xff;
397
+ checksum += temp3;
398
+ out[outIdx++] = temp3;
399
+ inIdx += 4;
400
+ inSz -= 4;
401
+ }
402
+
403
+ const a = invlut[address.charCodeAt(inIdx + 0)];
404
+ const b = invlut[address.charCodeAt(inIdx + 1)];
405
+ const c = invlut[address.charCodeAt(inIdx + 2)];
406
+ const d = invlut[address.charCodeAt(inIdx + 3)];
407
+ if (a < 0 || b < 0 || c < 0 || d < 0) {
408
+ throw new Error(`Invalid Thru address character at ${inIdx}`);
409
+ }
410
+ const triple = (a << 18) | (b << 12) | (c << 6) | d;
411
+ const temp1 = (triple >> 16) & 0xff;
412
+ checksum += temp1;
413
+ out[outIdx++] = temp1;
414
+ const temp2 = (triple >> 8) & 0xff;
415
+ checksum += temp2;
416
+ out[outIdx++] = temp2;
417
+ const incomingChecksum = triple & 0xff;
418
+ if ((checksum & 0xff) !== incomingChecksum) {
419
+ throw new Error("Invalid Thru address checksum");
420
+ }
421
+
422
+ return out;
423
+ }
424
+
425
+ function encodeThruAddress(bytes: Uint8Array): string {
426
+ if (bytes.length !== 32) {
427
+ throw new Error(`Expected 32 bytes, got ${bytes.length}`);
428
+ }
429
+
430
+ function maskForBits(bits: number): number {
431
+ return bits === 0 ? 0 : (1 << bits) - 1;
432
+ }
433
+
434
+ let output = "ta";
435
+ let checksum = 0;
436
+ let accumulator = 0;
437
+ let bitsCollected = 0;
438
+
439
+ for (let i = 0; i < 30; i++) {
440
+ const byte = bytes[i];
441
+ checksum += byte;
442
+ accumulator = (accumulator << 8) | byte;
443
+ bitsCollected += 8;
444
+ while (bitsCollected >= 6) {
445
+ const index = (accumulator >> (bitsCollected - 6)) & 0x3f;
446
+ output += BASE64_URL_ALPHABET[index];
447
+ bitsCollected -= 6;
448
+ accumulator &= maskForBits(bitsCollected);
449
+ }
450
+ }
451
+
452
+ const secondLast = bytes[30];
453
+ checksum += secondLast;
454
+ accumulator = (accumulator << 8) | secondLast;
455
+ bitsCollected += 8;
456
+
457
+ const last = bytes[31];
458
+ checksum += last;
459
+ accumulator = (accumulator << 8) | last;
460
+ bitsCollected += 8;
461
+
462
+ accumulator = (accumulator << 8) | (checksum & 0xff);
463
+ bitsCollected += 8;
464
+
465
+ while (bitsCollected >= 6) {
466
+ const index = (accumulator >> (bitsCollected - 6)) & 0x3f;
467
+ output += BASE64_URL_ALPHABET[index];
468
+ bitsCollected -= 6;
469
+ accumulator &= maskForBits(bitsCollected);
470
+ }
471
+
472
+ return output;
473
+ }
474
+
475
+ function base64Decode(str: string): Uint8Array {
476
+ const binaryStr = atob(str);
477
+ const bytes = new Uint8Array(binaryStr.length);
478
+ for (let i = 0; i < binaryStr.length; i++) {
479
+ bytes[i] = binaryStr.charCodeAt(i);
480
+ }
481
+ return bytes;
482
+ }