@d9-network/ink 0.0.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,571 @@
1
+ /**
2
+ * D9 Ink Contract implementation
3
+ *
4
+ * Provides a type-safe interface for interacting with ink! smart contracts
5
+ * using state_call + ContractsApi_call instead of ReviveApi.
6
+ */
7
+
8
+ import type { PolkadotSigner, SS58String } from "polkadot-api";
9
+ import { Binary } from "polkadot-api";
10
+ import { fromHex } from "polkadot-api/utils";
11
+ import {
12
+ getInkLookup,
13
+ getInkDynamicBuilder,
14
+ type InkDescriptors,
15
+ type InkCallableDescriptor,
16
+ type InkStorageDescriptor,
17
+ type InkMetadata,
18
+ type Event,
19
+ } from "@polkadot-api/ink-contracts";
20
+ import { ss58Decode } from "@polkadot-labs/hdkd-helpers";
21
+
22
+ import type {
23
+ D9InkContract,
24
+ QueryOptions,
25
+ QueryResult,
26
+ SendOptions,
27
+ SendableTransaction,
28
+ TxResult,
29
+ ContractStorage,
30
+ CreateContractOptions,
31
+ ResponseDecoder,
32
+ } from "./types";
33
+ import { encodeContractCall } from "./encode";
34
+ import {
35
+ decodeContractCallResult,
36
+ unwrapInkResult,
37
+ isLangError,
38
+ } from "./decode";
39
+ import { createCodecRegistry } from "./codec-builder";
40
+ import { ContractEventParser } from "./events";
41
+ import { createContractEventStream } from "./subscriptions";
42
+ import type {
43
+ DecodedContractEvent,
44
+ EventSubscriptionOptions,
45
+ } from "./event-types";
46
+ import {
47
+ ContractError,
48
+ MetadataError,
49
+ DecodeError,
50
+ LangError,
51
+ TimeoutError,
52
+ AbortedError,
53
+ TransactionError,
54
+ } from "./errors";
55
+
56
+ /**
57
+ * Patch LangError type in ink metadata to fix the missing index 0 variant issue.
58
+ * Uses structuredClone for efficient deep cloning.
59
+ */
60
+ function patchLangErrorInMetadata(metadata: InkMetadata): InkMetadata {
61
+ const patched = structuredClone(metadata);
62
+
63
+ for (const typeEntry of patched.types) {
64
+ const path = typeEntry.type?.path;
65
+ const def = typeEntry.type?.def as {
66
+ variant?: {
67
+ variants: Array<{
68
+ index: number;
69
+ name: string;
70
+ fields: unknown[];
71
+ docs: string[];
72
+ }>;
73
+ };
74
+ };
75
+ if (
76
+ path &&
77
+ Array.isArray(path) &&
78
+ path.includes("LangError") &&
79
+ def?.variant
80
+ ) {
81
+ const variants = def.variant.variants;
82
+ if (Array.isArray(variants)) {
83
+ const hasIndex0 = variants.some((v) => v.index === 0);
84
+ if (!hasIndex0) {
85
+ variants.unshift({
86
+ index: 0,
87
+ name: "_Placeholder",
88
+ fields: [],
89
+ docs: [],
90
+ });
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ return patched;
97
+ }
98
+
99
+ /**
100
+ * Convert SS58 address to bytes
101
+ */
102
+ function ss58ToBytes(address: SS58String): Uint8Array {
103
+ const [publicKey] = ss58Decode(address);
104
+ return publicKey;
105
+ }
106
+
107
+ /**
108
+ * Create a promise that rejects after a timeout
109
+ */
110
+ function createTimeout<T>(
111
+ ms: number,
112
+ label: string,
113
+ ): { promise: Promise<T>; clear: () => void } {
114
+ let timeoutId: ReturnType<typeof setTimeout>;
115
+ const promise = new Promise<T>((_, reject) => {
116
+ timeoutId = setTimeout(() => {
117
+ reject(new TimeoutError(label, ms));
118
+ }, ms);
119
+ });
120
+ return {
121
+ promise,
122
+ clear: () => clearTimeout(timeoutId),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Check if AbortSignal is aborted and throw if so
128
+ */
129
+ function checkAborted(signal: AbortSignal | undefined, label: string): void {
130
+ if (signal?.aborted) {
131
+ throw new AbortedError(label, signal.reason);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Create a D9 Ink Contract instance
137
+ */
138
+ export function createD9InkContract<
139
+ S extends InkStorageDescriptor,
140
+ M extends InkCallableDescriptor,
141
+ C extends InkCallableDescriptor,
142
+ E extends Event,
143
+ >(
144
+ descriptor: InkDescriptors<S, M, C, E>,
145
+ address: SS58String,
146
+ options: CreateContractOptions,
147
+ ): D9InkContract<M> {
148
+ const { client, typedApi, defaultQueryOptions = {}, defaultSendOptions = {} } = options;
149
+
150
+ if (!descriptor.metadata) {
151
+ throw new MetadataError("Contract descriptor must include metadata");
152
+ }
153
+
154
+ // Patch and prepare metadata
155
+ const patchedMetadata = patchLangErrorInMetadata(descriptor.metadata);
156
+ const lookup = getInkLookup(patchedMetadata);
157
+ const builder = getInkDynamicBuilder(lookup);
158
+
159
+ // Build auto-generated codecs
160
+ let codecRegistry: Map<string, ResponseDecoder>;
161
+ try {
162
+ codecRegistry = createCodecRegistry(patchedMetadata);
163
+ } catch (error) {
164
+ console.warn("Failed to auto-generate codecs from metadata:", error);
165
+ codecRegistry = new Map();
166
+ }
167
+
168
+ // Convert address to bytes
169
+ const addressBytes = ss58ToBytes(address);
170
+
171
+ // Cache for message codecs
172
+ type MessageCodec = ReturnType<typeof builder.buildMessage>;
173
+ const messageCodecCache = new Map<string, MessageCodec>();
174
+
175
+ function getMessageCodec(label: string): MessageCodec {
176
+ const cached = messageCodecCache.get(label);
177
+ if (cached) {
178
+ return cached;
179
+ }
180
+ const codec = builder.buildMessage(label);
181
+ messageCodecCache.set(label, codec);
182
+ return codec;
183
+ }
184
+
185
+ /**
186
+ * Get the decoder for a message
187
+ */
188
+ function getDecoder(label: string): ResponseDecoder | null {
189
+ return codecRegistry.get(label) ?? null;
190
+ }
191
+
192
+ /**
193
+ * Execute a query (dry-run)
194
+ */
195
+ async function executeQuery<K extends keyof M & string>(
196
+ method: K,
197
+ queryOptions: QueryOptions<M[K]["message"]>,
198
+ ): Promise<QueryResult<M[K]["response"]>> {
199
+ const opts = { ...defaultQueryOptions, ...queryOptions };
200
+ const { origin, args, value = 0n, signal, timeout, at } = opts;
201
+
202
+ try {
203
+ checkAborted(signal, method);
204
+
205
+ const originBytes = ss58ToBytes(origin);
206
+ const codec = getMessageCodec(method);
207
+
208
+ // Encode the call
209
+ const callData = codec.call.enc(
210
+ (args ?? {}) as Parameters<typeof codec.call.enc>[0],
211
+ );
212
+
213
+ // Create the state_call message
214
+ const message = encodeContractCall(
215
+ originBytes,
216
+ addressBytes,
217
+ callData,
218
+ value,
219
+ );
220
+
221
+ // Get block hash
222
+ const blockHash = at ?? (await client.getFinalizedBlock()).hash;
223
+
224
+ checkAborted(signal, method);
225
+
226
+ // Execute state_call with optional timeout
227
+ const executeCall = async () => {
228
+ const response = (await client._request("state_call", [
229
+ "ContractsApi_call",
230
+ message,
231
+ blockHash,
232
+ ])) as `0x${string}`;
233
+
234
+ return fromHex(response);
235
+ };
236
+
237
+ let rawResponse: Uint8Array;
238
+ if (timeout) {
239
+ const { promise: timeoutPromise, clear } = createTimeout<Uint8Array>(
240
+ timeout,
241
+ method,
242
+ );
243
+ try {
244
+ rawResponse = await Promise.race([executeCall(), timeoutPromise]);
245
+ } finally {
246
+ clear();
247
+ }
248
+ } else {
249
+ rawResponse = await executeCall();
250
+ }
251
+
252
+ checkAborted(signal, method);
253
+
254
+ // Decode the ContractsApi_call response
255
+ const callResult = decodeContractCallResult(rawResponse);
256
+
257
+ // Check for execution error
258
+ if (!callResult.success) {
259
+ return {
260
+ success: false,
261
+ error: new ContractError(
262
+ `Contract execution failed: ${callResult.debugMessage}`,
263
+ "CONTRACT_ERROR",
264
+ method,
265
+ ),
266
+ };
267
+ }
268
+
269
+ // Check for LangError
270
+ if (isLangError(callResult.data)) {
271
+ return {
272
+ success: false,
273
+ error: new LangError(method, callResult.data[1] ?? 1),
274
+ };
275
+ }
276
+
277
+ // Unwrap the Result<T, LangError>
278
+ const innerData = unwrapInkResult(callResult.data);
279
+
280
+ // Decode the response
281
+ let decodedResponse: M[K]["response"];
282
+ const decoder = getDecoder(method);
283
+
284
+ if (decoder) {
285
+ try {
286
+ decodedResponse = decoder.dec(innerData) as M[K]["response"];
287
+ } catch (decodeError) {
288
+ console.warn("D9InkContract: Failed to decode response:", decodeError);
289
+ // Fall back to papi's value codec
290
+ const fullResult = new Uint8Array(1 + innerData.length);
291
+ fullResult[0] = 0; // Ok variant
292
+ fullResult.set(innerData, 1);
293
+
294
+ try {
295
+ const papiResult = codec.value.dec(fullResult);
296
+ if (
297
+ papiResult !== null &&
298
+ typeof papiResult === "object" &&
299
+ "success" in papiResult &&
300
+ "value" in papiResult
301
+ ) {
302
+ if (papiResult.success) {
303
+ decodedResponse = papiResult.value as M[K]["response"];
304
+ } else {
305
+ return {
306
+ success: false,
307
+ error: new DecodeError(
308
+ method,
309
+ `Contract returned error: ${JSON.stringify(papiResult.value)}`,
310
+ papiResult.value,
311
+ ),
312
+ };
313
+ }
314
+ } else {
315
+ decodedResponse = papiResult as M[K]["response"];
316
+ }
317
+ } catch (error) {
318
+ return {
319
+ success: false,
320
+ error: new DecodeError(
321
+ method,
322
+ `Failed to decode response: ${error instanceof Error ? error.message : String(error)}`,
323
+ { error },
324
+ ),
325
+ };
326
+ }
327
+ }
328
+ } else {
329
+ // No custom decoder, use papi's codec
330
+ const fullResult = new Uint8Array(1 + innerData.length);
331
+ fullResult[0] = 0; // Ok variant
332
+ fullResult.set(innerData, 1);
333
+
334
+ const papiResult = codec.value.dec(fullResult);
335
+ if (
336
+ papiResult !== null &&
337
+ typeof papiResult === "object" &&
338
+ "success" in papiResult &&
339
+ "value" in papiResult
340
+ ) {
341
+ if (papiResult.success) {
342
+ decodedResponse = papiResult.value as M[K]["response"];
343
+ } else {
344
+ return {
345
+ success: false,
346
+ error: new DecodeError(
347
+ method,
348
+ `Contract returned error: ${JSON.stringify(papiResult.value)}`,
349
+ papiResult.value,
350
+ ),
351
+ };
352
+ }
353
+ } else {
354
+ decodedResponse = papiResult as M[K]["response"];
355
+ }
356
+ }
357
+
358
+ // Return success with QuerySuccessValue fields spread to same level
359
+ return {
360
+ success: true,
361
+ value: decodedResponse,
362
+ events: [],
363
+ gasConsumed: callResult.gas.gasConsumed,
364
+ gasRequired: callResult.gas.gasRequired,
365
+ storageDeposit: callResult.storageDeposit.amount,
366
+ send: () => createSendableTransaction(method, {
367
+ origin,
368
+ args,
369
+ value,
370
+ gasLimit: callResult.gas.gasRequired,
371
+ }),
372
+ };
373
+ } catch (error) {
374
+ if (error instanceof ContractError) {
375
+ return { success: false, error };
376
+ }
377
+ return {
378
+ success: false,
379
+ error: new ContractError(
380
+ error instanceof Error ? error.message : String(error),
381
+ "NETWORK_ERROR",
382
+ method,
383
+ ),
384
+ };
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Create a sendable transaction
390
+ */
391
+ function createSendableTransaction<K extends keyof M & string>(
392
+ method: K,
393
+ sendOptions: SendOptions<M[K]["message"]>,
394
+ ): SendableTransaction<M[K]["response"]> {
395
+ const opts = { ...defaultSendOptions, ...sendOptions };
396
+ const { origin, args, value = 0n, gasLimit, storageDepositLimit } = opts;
397
+
398
+ const originBytes = ss58ToBytes(origin);
399
+ const codec = getMessageCodec(method);
400
+
401
+ // Encode the call
402
+ const callData = codec.call.enc(
403
+ (args ?? {}) as Parameters<typeof codec.call.enc>[0],
404
+ );
405
+
406
+ return {
407
+ getEncodedData: () => callData,
408
+
409
+ async signAndSubmit(signer: PolkadotSigner): Promise<TxResult<M[K]["response"]>> {
410
+ if (!typedApi) {
411
+ throw new TransactionError(
412
+ method,
413
+ "typedApi is required for transaction submission. Pass typedApi in SDK options.",
414
+ );
415
+ }
416
+
417
+ try {
418
+ // First do a dry-run to get gas estimate if not provided
419
+ let gas = gasLimit;
420
+ if (!gas) {
421
+ const message = encodeContractCall(
422
+ originBytes,
423
+ addressBytes,
424
+ callData,
425
+ value,
426
+ );
427
+ const blockHash = (await client.getFinalizedBlock()).hash;
428
+ const response = (await client._request("state_call", [
429
+ "ContractsApi_call",
430
+ message,
431
+ blockHash,
432
+ ])) as `0x${string}`;
433
+ const callResult = decodeContractCallResult(fromHex(response));
434
+ gas = callResult.gas.gasRequired;
435
+ }
436
+
437
+ // Build the transaction using typedApi
438
+ const api = typedApi as {
439
+ tx: {
440
+ Contracts: {
441
+ call: (params: {
442
+ dest: { type: "Id"; value: SS58String };
443
+ value: bigint;
444
+ gas_limit: { ref_time: bigint; proof_size: bigint };
445
+ storage_deposit_limit: bigint | undefined;
446
+ data: Binary;
447
+ }) => {
448
+ signAndSubmit: (
449
+ signer: PolkadotSigner,
450
+ options?: { at?: "best" | "finalized" }
451
+ ) => Promise<{
452
+ txHash: string;
453
+ block: { hash: string; number: number };
454
+ events: unknown[];
455
+ }>;
456
+ };
457
+ };
458
+ };
459
+ };
460
+
461
+ const tx = api.tx.Contracts.call({
462
+ dest: { type: "Id", value: address },
463
+ value,
464
+ gas_limit: {
465
+ ref_time: gas.refTime,
466
+ proof_size: gas.proofSize,
467
+ },
468
+ storage_deposit_limit: storageDepositLimit,
469
+ data: Binary.fromBytes(callData),
470
+ });
471
+
472
+ const txResult = await tx.signAndSubmit(signer, { at: "finalized" });
473
+
474
+ return {
475
+ ok: true,
476
+ txHash: txResult.txHash,
477
+ block: txResult.block,
478
+ events: txResult.events ?? [],
479
+ };
480
+ } catch (error) {
481
+ return {
482
+ ok: false,
483
+ txHash: "",
484
+ block: { hash: "", number: 0 },
485
+ events: [],
486
+ dispatchError: error,
487
+ };
488
+ }
489
+ },
490
+ };
491
+ }
492
+
493
+ /**
494
+ * Create send method that returns a sendable transaction
495
+ */
496
+ function send<K extends keyof M & string>(
497
+ method: K,
498
+ sendOptions: SendOptions<M[K]["message"]>,
499
+ ): SendableTransaction<M[K]["response"]> {
500
+ return createSendableTransaction(method, sendOptions);
501
+ }
502
+
503
+ /**
504
+ * Create storage query interface
505
+ */
506
+ function getStorage(): ContractStorage {
507
+ return {
508
+ async getRoot() {
509
+ // TODO: Implement storage root query
510
+ console.warn("D9InkContract: getRoot not implemented");
511
+ return {
512
+ success: false,
513
+ value: new ContractError(
514
+ "Storage queries not yet implemented",
515
+ "METADATA_ERROR",
516
+ ),
517
+ };
518
+ },
519
+
520
+ async getNested(path: string, ..._keys: unknown[]) {
521
+ // TODO: Implement nested storage query
522
+ console.warn("D9InkContract: getNested not implemented");
523
+ return {
524
+ success: false,
525
+ value: new ContractError(
526
+ `Storage query for "${path}" not yet implemented`,
527
+ "METADATA_ERROR",
528
+ ),
529
+ };
530
+ },
531
+ };
532
+ }
533
+
534
+ /**
535
+ * Filter events for this contract
536
+ */
537
+ function filterEvents(events: unknown[]): DecodedContractEvent[] {
538
+ const parser = new ContractEventParser(patchedMetadata, address);
539
+ return parser.filterEvents(events);
540
+ }
541
+
542
+ /**
543
+ * Subscribe to contract events as an RxJS Observable
544
+ *
545
+ * @param options - Subscription options (contractAddress is automatically set)
546
+ * @param options.getEvents - Function to fetch System.Events at a block hash
547
+ * @param options.eventLabels - Optional filter for specific event names
548
+ * @param options.fromBlock - Optional starting block number
549
+ */
550
+ function subscribeToEvents(
551
+ options: Omit<EventSubscriptionOptions, "contractAddress">,
552
+ ) {
553
+ return createContractEventStream(client, patchedMetadata, {
554
+ ...options,
555
+ contractAddress: address,
556
+ });
557
+ }
558
+
559
+ // Type assertion needed because the runtime correctly unwraps MessageResult
560
+ // but TypeScript doesn't know that. The UnwrapMessageResult type in D9InkContract
561
+ // matches what we actually return at runtime.
562
+ return {
563
+ address,
564
+ metadata: patchedMetadata,
565
+ query: executeQuery,
566
+ send,
567
+ getStorage,
568
+ filterEvents,
569
+ subscribeToEvents,
570
+ } as D9InkContract<M>;
571
+ }