@qubic.ts/sdk 0.1.0

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,234 @@
1
+ import {
2
+ buildSignedTransaction,
3
+ identityFromSeed,
4
+ privateKeyFromSeed,
5
+ publicKeyFromIdentity,
6
+ publicKeyFromSeed,
7
+ transactionId,
8
+ } from "@qubic.ts/core";
9
+ import type { BroadcastTransactionResult, QueryTransaction } from "./rpc/client.js";
10
+ import type { TickHelpers } from "./tick.js";
11
+ import type { TxHelpers } from "./tx/tx.js";
12
+ import type { TxQueue } from "./tx/tx-queue.js";
13
+ import type { SeedVault } from "./vault/types.js";
14
+
15
+ export type TransactionHelpersConfig = Readonly<{
16
+ tick: TickHelpers;
17
+ tx: TxHelpers;
18
+ txQueue?: TxQueue;
19
+ vault?: SeedVault;
20
+ }>;
21
+
22
+ export type SeedSourceInput =
23
+ | Readonly<{ fromSeed: string; fromVault?: never }>
24
+ | Readonly<{ fromVault: string; fromSeed?: never }>;
25
+
26
+ export type BuildSignedTransactionInput = SeedSourceInput &
27
+ Readonly<{
28
+ toIdentity: string;
29
+ amount: bigint;
30
+ targetTick?: bigint | number;
31
+ inputType?: number;
32
+ inputBytes?: Uint8Array;
33
+ }>;
34
+
35
+ export type BuiltTransaction = Readonly<{
36
+ txBytes: Uint8Array;
37
+ /** Deterministic transaction id derived from tx bytes. */
38
+ txId: string;
39
+ targetTick: bigint;
40
+ }>;
41
+
42
+ export type SendTransactionResult = Readonly<{
43
+ txBytes: Uint8Array;
44
+ /** Deterministic transaction id derived from tx bytes. */
45
+ txId: string;
46
+ /** Transaction id returned by the RPC broadcast call (used for confirmation). */
47
+ networkTxId: string;
48
+ targetTick: bigint;
49
+ broadcast: BroadcastTransactionResult;
50
+ }>;
51
+
52
+ export type SendTransactionReceipt = SendTransactionResult &
53
+ Readonly<{
54
+ confirmedTransaction: QueryTransaction;
55
+ }>;
56
+
57
+ export class QueuedTransactionError extends Error {
58
+ override name = "QueuedTransactionError";
59
+
60
+ constructor(
61
+ message: string,
62
+ readonly details: Readonly<{
63
+ status: string;
64
+ error?: unknown;
65
+ }>,
66
+ ) {
67
+ super(message);
68
+ }
69
+ }
70
+
71
+ export type SendAndConfirmTransactionInput = BuildSignedTransactionInput &
72
+ Readonly<{
73
+ timeoutMs?: number;
74
+ pollIntervalMs?: number;
75
+ signal?: AbortSignal;
76
+ }>;
77
+
78
+ export type TransactionHelpers = Readonly<{
79
+ buildSigned(input: BuildSignedTransactionInput): Promise<BuiltTransaction>;
80
+ send(input: BuildSignedTransactionInput): Promise<SendTransactionResult>;
81
+ sendAndConfirm(input: SendAndConfirmTransactionInput): Promise<SendTransactionResult>;
82
+ sendQueued(input: SendAndConfirmTransactionInput): Promise<SendTransactionResult>;
83
+ sendAndConfirmWithReceipt(input: SendAndConfirmTransactionInput): Promise<SendTransactionReceipt>;
84
+ }>;
85
+
86
+ export function createTransactionHelpers(config: TransactionHelpersConfig): TransactionHelpers {
87
+ const helpers: TransactionHelpers = {
88
+ async buildSigned(input: BuildSignedTransactionInput): Promise<BuiltTransaction> {
89
+ const targetTick =
90
+ input.targetTick !== undefined
91
+ ? toBigint(input.targetTick)
92
+ : await config.tick.getSuggestedTargetTick();
93
+
94
+ const tickU32 = toU32Number(targetTick, "targetTick");
95
+ const seed = await resolveSeed(input, config.vault);
96
+ const sourcePublicKey32 = await publicKeyFromSeed(seed);
97
+ const destinationPublicKey32 = publicKeyFromIdentity(input.toIdentity);
98
+ const secretKey32 = await privateKeyFromSeed(seed);
99
+
100
+ const txBytes = await buildSignedTransaction(
101
+ {
102
+ sourcePublicKey32,
103
+ destinationPublicKey32,
104
+ amount: input.amount,
105
+ tick: tickU32,
106
+ inputType: input.inputType ?? 0,
107
+ inputBytes: input.inputBytes,
108
+ },
109
+ secretKey32,
110
+ );
111
+ const txId = await transactionId(txBytes);
112
+ return { txBytes, txId, targetTick };
113
+ },
114
+
115
+ async send(input: BuildSignedTransactionInput): Promise<SendTransactionResult> {
116
+ const built = await helpers.buildSigned(input);
117
+ const broadcast = await config.tx.broadcastSigned(built.txBytes);
118
+ return {
119
+ ...built,
120
+ networkTxId: broadcast.transactionId,
121
+ broadcast,
122
+ };
123
+ },
124
+
125
+ async sendAndConfirm(input: SendAndConfirmTransactionInput): Promise<SendTransactionResult> {
126
+ if (config.txQueue) return helpers.sendQueued(input);
127
+
128
+ const built = await helpers.buildSigned(input);
129
+ const broadcast = await config.tx.broadcastSigned(built.txBytes);
130
+ await config.tx.waitForConfirmation({
131
+ txId: broadcast.transactionId,
132
+ targetTick: built.targetTick,
133
+ timeoutMs: input.timeoutMs,
134
+ pollIntervalMs: input.pollIntervalMs,
135
+ signal: input.signal,
136
+ });
137
+ return {
138
+ ...built,
139
+ networkTxId: broadcast.transactionId,
140
+ broadcast,
141
+ };
142
+ },
143
+
144
+ async sendAndConfirmWithReceipt(
145
+ input: SendAndConfirmTransactionInput,
146
+ ): Promise<SendTransactionReceipt> {
147
+ const sent = await helpers.sendAndConfirm(input);
148
+ const confirmedTransaction = await config.tx.waitForConfirmedTransaction({
149
+ txId: sent.networkTxId,
150
+ targetTick: sent.targetTick,
151
+ timeoutMs: input.timeoutMs,
152
+ pollIntervalMs: input.pollIntervalMs,
153
+ signal: input.signal,
154
+ });
155
+ return { ...sent, confirmedTransaction };
156
+ },
157
+
158
+ async sendQueued(input: SendAndConfirmTransactionInput): Promise<SendTransactionResult> {
159
+ const txQueue = config.txQueue;
160
+ if (!txQueue) throw new Error("Transaction queue is not configured");
161
+
162
+ const sourceIdentity = await resolveSourceIdentity(input, config.vault);
163
+ const built = await helpers.buildSigned(input);
164
+
165
+ const queued = await txQueue.enqueue({
166
+ sourceIdentity,
167
+ targetTick: built.targetTick,
168
+ submit: async ({ signal }) => {
169
+ if (signal.aborted) throw new Error("aborted");
170
+ const broadcast = await config.tx.broadcastSigned(built.txBytes);
171
+ return { txId: broadcast.transactionId, result: broadcast };
172
+ },
173
+ confirm: ({ txId, targetTick, signal }) =>
174
+ config.tx.waitForConfirmation({
175
+ txId,
176
+ targetTick,
177
+ timeoutMs: input.timeoutMs,
178
+ pollIntervalMs: input.pollIntervalMs,
179
+ signal,
180
+ }),
181
+ });
182
+
183
+ if (queued.status !== "confirmed") {
184
+ throw new QueuedTransactionError("Transaction queue did not confirm", {
185
+ status: queued.status,
186
+ error: queued.error,
187
+ });
188
+ }
189
+ const broadcast = queued.result as BroadcastTransactionResult | undefined;
190
+ if (!broadcast) throw new Error("Transaction queue missing broadcast result");
191
+
192
+ return {
193
+ ...built,
194
+ networkTxId: broadcast.transactionId,
195
+ broadcast,
196
+ };
197
+ },
198
+ };
199
+
200
+ return helpers;
201
+ }
202
+
203
+ async function resolveSeed(input: SeedSourceInput, vault?: SeedVault): Promise<string> {
204
+ if (hasFromSeed(input)) return input.fromSeed;
205
+ if (!vault) throw new Error("Vault is not configured");
206
+ return vault.getSeed(input.fromVault);
207
+ }
208
+
209
+ async function resolveSourceIdentity(input: SeedSourceInput, vault?: SeedVault): Promise<string> {
210
+ if (hasFromSeed(input)) {
211
+ return identityFromSeed(input.fromSeed);
212
+ }
213
+ if (!vault) throw new Error("Vault is not configured");
214
+ return await vault.getIdentity(input.fromVault);
215
+ }
216
+
217
+ function hasFromSeed(input: SeedSourceInput): input is Readonly<{ fromSeed: string }> {
218
+ return "fromSeed" in input && typeof input.fromSeed === "string";
219
+ }
220
+
221
+ function toBigint(value: bigint | number): bigint {
222
+ if (typeof value === "bigint") return value;
223
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
224
+ throw new TypeError("Expected an integer");
225
+ }
226
+ return BigInt(value);
227
+ }
228
+
229
+ function toU32Number(value: bigint, name: string): number {
230
+ if (value < 0n || value > 0xffff_ffffn) {
231
+ throw new RangeError(`${name} must fit in uint32`);
232
+ }
233
+ return Number(value);
234
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ buildSignedTransaction,
4
+ privateKeyFromSeed,
5
+ publicKeyFromIdentity,
6
+ publicKeyFromSeed,
7
+ transactionId,
8
+ } from "@qubic.ts/core";
9
+ import type { TickHelpers } from "./tick.js";
10
+ import { createTransactionHelpers } from "./transactions.js";
11
+ import { createTransferHelpers } from "./transfers.js";
12
+ import type { TxHelpers } from "./tx/tx.js";
13
+
14
+ describe("transfers", () => {
15
+ it("builds the same signed tx as core (simple transfer)", async () => {
16
+ const seed = "jvhbyzjinlyutyuhsweuxiwootqoevjqwqmdhjeohrytxjxidpbcfyg";
17
+ const toIdentity = "AFZPUAIYVPNUYGJRQVLUKOPPVLHAZQTGLYAAUUNBXFTVTAMSBKQBLEIEPCVJ";
18
+ const targetTick = 12345n;
19
+
20
+ const tick: TickHelpers = {
21
+ async getSuggestedTargetTick() {
22
+ return 999n;
23
+ },
24
+ };
25
+ const tx: TxHelpers = {
26
+ async broadcastSigned() {
27
+ throw new Error("not used");
28
+ },
29
+ async waitForConfirmation() {
30
+ throw new Error("not used");
31
+ },
32
+ async waitForConfirmedTransaction() {
33
+ throw new Error("not used");
34
+ },
35
+ };
36
+
37
+ const transactions = createTransactionHelpers({ tick, tx });
38
+ const transfers = createTransferHelpers({ transactions });
39
+ const built = await transfers.buildSignedTransfer({
40
+ fromSeed: seed,
41
+ toIdentity,
42
+ amount: 1n,
43
+ targetTick,
44
+ });
45
+
46
+ const sourcePublicKey32 = await publicKeyFromSeed(seed);
47
+ const destinationPublicKey32 = publicKeyFromIdentity(toIdentity);
48
+ const secretKey32 = await privateKeyFromSeed(seed);
49
+ const expected = await buildSignedTransaction(
50
+ { sourcePublicKey32, destinationPublicKey32, amount: 1n, tick: 12345 },
51
+ secretKey32,
52
+ );
53
+ const expectedId = await transactionId(expected);
54
+
55
+ expect(built.targetTick).toBe(targetTick);
56
+ expect(built.txBytes).toEqual(expected);
57
+ expect(built.txId).toBe(expectedId);
58
+ });
59
+ });
@@ -0,0 +1,132 @@
1
+ import type { BroadcastTransactionResult, QueryTransaction } from "./rpc/client.js";
2
+ import type { SeedSourceInput, TransactionHelpers } from "./transactions.js";
3
+
4
+ export type TransferHelpersConfig = Readonly<{
5
+ transactions: TransactionHelpers;
6
+ }>;
7
+
8
+ export type BuildSignedTransferInput = SeedSourceInput &
9
+ Readonly<{
10
+ toIdentity: string;
11
+ amount: bigint;
12
+ targetTick?: bigint | number;
13
+ }>;
14
+
15
+ export type SignedTransfer = Readonly<{
16
+ txBytes: Uint8Array;
17
+ txId: string;
18
+ targetTick: bigint;
19
+ }>;
20
+
21
+ export type SendTransferResult = Readonly<{
22
+ txBytes: Uint8Array;
23
+ txId: string;
24
+ networkTxId: string;
25
+ targetTick: bigint;
26
+ broadcast: BroadcastTransactionResult;
27
+ }>;
28
+
29
+ export type SendTransferReceipt = SendTransferResult &
30
+ Readonly<{
31
+ confirmedTransaction: QueryTransaction;
32
+ }>;
33
+
34
+ export type SendAndConfirmInput = BuildSignedTransferInput &
35
+ Readonly<{
36
+ timeoutMs?: number;
37
+ pollIntervalMs?: number;
38
+ signal?: AbortSignal;
39
+ }>;
40
+
41
+ export type TransferHelpers = Readonly<{
42
+ buildSignedTransfer(input: BuildSignedTransferInput): Promise<SignedTransfer>;
43
+ send(input: BuildSignedTransferInput): Promise<SendTransferResult>;
44
+ sendAndConfirm(input: SendAndConfirmInput): Promise<SendTransferResult>;
45
+ sendAndConfirmWithReceipt(input: SendAndConfirmInput): Promise<SendTransferReceipt>;
46
+ sendFromVault(
47
+ input: Omit<BuildSignedTransferInput, "fromSeed" | "fromVault"> &
48
+ Readonly<{ fromVault: string }>,
49
+ ): Promise<SendTransferResult>;
50
+ sendAndConfirmFromVault(
51
+ input: Omit<SendAndConfirmInput, "fromSeed" | "fromVault"> & Readonly<{ fromVault: string }>,
52
+ ): Promise<SendTransferResult>;
53
+ }>;
54
+
55
+ export function createTransferHelpers(config: TransferHelpersConfig): TransferHelpers {
56
+ const helpers: TransferHelpers = {
57
+ async buildSignedTransfer(input: BuildSignedTransferInput): Promise<SignedTransfer> {
58
+ const built = await config.transactions.buildSigned({
59
+ ...input,
60
+ toIdentity: input.toIdentity,
61
+ amount: input.amount,
62
+ targetTick: input.targetTick,
63
+ });
64
+ return { txBytes: built.txBytes, txId: built.txId, targetTick: built.targetTick };
65
+ },
66
+
67
+ async send(input: BuildSignedTransferInput): Promise<SendTransferResult> {
68
+ const sent = await config.transactions.send({
69
+ ...input,
70
+ toIdentity: input.toIdentity,
71
+ amount: input.amount,
72
+ targetTick: input.targetTick,
73
+ });
74
+ return {
75
+ txBytes: sent.txBytes,
76
+ txId: sent.txId,
77
+ networkTxId: sent.networkTxId,
78
+ targetTick: sent.targetTick,
79
+ broadcast: sent.broadcast,
80
+ };
81
+ },
82
+
83
+ async sendAndConfirm(input: SendAndConfirmInput): Promise<SendTransferResult> {
84
+ const sent = await config.transactions.sendAndConfirm({
85
+ ...input,
86
+ toIdentity: input.toIdentity,
87
+ amount: input.amount,
88
+ targetTick: input.targetTick,
89
+ timeoutMs: input.timeoutMs,
90
+ pollIntervalMs: input.pollIntervalMs,
91
+ signal: input.signal,
92
+ });
93
+ return {
94
+ txBytes: sent.txBytes,
95
+ txId: sent.txId,
96
+ networkTxId: sent.networkTxId,
97
+ targetTick: sent.targetTick,
98
+ broadcast: sent.broadcast,
99
+ };
100
+ },
101
+
102
+ async sendAndConfirmWithReceipt(input: SendAndConfirmInput): Promise<SendTransferReceipt> {
103
+ const sent = await config.transactions.sendAndConfirmWithReceipt({
104
+ ...input,
105
+ toIdentity: input.toIdentity,
106
+ amount: input.amount,
107
+ targetTick: input.targetTick,
108
+ timeoutMs: input.timeoutMs,
109
+ pollIntervalMs: input.pollIntervalMs,
110
+ signal: input.signal,
111
+ });
112
+ return {
113
+ txBytes: sent.txBytes,
114
+ txId: sent.txId,
115
+ networkTxId: sent.networkTxId,
116
+ targetTick: sent.targetTick,
117
+ broadcast: sent.broadcast,
118
+ confirmedTransaction: sent.confirmedTransaction,
119
+ };
120
+ },
121
+
122
+ async sendFromVault(input): Promise<SendTransferResult> {
123
+ return helpers.send({ ...input, fromVault: input.fromVault });
124
+ },
125
+
126
+ async sendAndConfirmFromVault(input): Promise<SendTransferResult> {
127
+ return helpers.sendAndConfirm({ ...input, fromVault: input.fromVault });
128
+ },
129
+ };
130
+
131
+ return helpers;
132
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { FetchLike } from "../http.js";
3
+ import { createRpcClient } from "../rpc/client.js";
4
+ import {
5
+ createTxConfirmationHelpers,
6
+ TxConfirmationTimeoutError,
7
+ TxNotFoundError,
8
+ } from "./confirm.js";
9
+
10
+ function createTestFetch() {
11
+ let lastProcessed = 0;
12
+ let getTxCalls = 0;
13
+
14
+ return async (...args: Parameters<FetchLike>) => {
15
+ const url = new URL(getUrl(args[0]));
16
+ const method = getMethod(args[0], args[1]);
17
+
18
+ if (method === "GET" && url.pathname === "/query/v1/getLastProcessedTick") {
19
+ lastProcessed = Math.min(lastProcessed + 5, 10);
20
+ return Response.json({ tickNumber: lastProcessed, epoch: 0, intervalInitialTick: 0 });
21
+ }
22
+
23
+ if (method === "POST" && url.pathname === "/query/v1/getTransactionByHash") {
24
+ getTxCalls++;
25
+ const body = readJsonBody(args[0], args[1]);
26
+ if (body.hash !== "tx") return new Response("bad hash", { status: 400 });
27
+
28
+ if (getTxCalls < 2) return new Response("not found", { status: 404 });
29
+ return Response.json({
30
+ hash: "tx",
31
+ amount: "0",
32
+ source: "S",
33
+ destination: "D",
34
+ tickNumber: 10,
35
+ timestamp: "0",
36
+ inputType: 0,
37
+ inputSize: 0,
38
+ inputData: "",
39
+ signature: "",
40
+ });
41
+ }
42
+
43
+ return new Response("not found", { status: 404 });
44
+ };
45
+ }
46
+
47
+ describe("tx confirmation", () => {
48
+ it("waits for lastProcessedTick >= targetTick and then finds tx", async () => {
49
+ const rpc = createRpcClient({ baseUrl: "https://example.test", fetch: createTestFetch() });
50
+ const confirm = createTxConfirmationHelpers({
51
+ rpc,
52
+ defaultTimeoutMs: 5_000,
53
+ defaultPollIntervalMs: 1,
54
+ });
55
+ await confirm.waitForConfirmation({ txId: "tx", targetTick: 10, pollIntervalMs: 1 });
56
+ });
57
+
58
+ it("returns the confirmed transaction (receipt)", async () => {
59
+ const rpc = createRpcClient({ baseUrl: "https://example.test", fetch: createTestFetch() });
60
+ const confirm = createTxConfirmationHelpers({
61
+ rpc,
62
+ defaultTimeoutMs: 5_000,
63
+ defaultPollIntervalMs: 1,
64
+ });
65
+ const tx = await confirm.waitForConfirmedTransaction({
66
+ txId: "tx",
67
+ targetTick: 10,
68
+ pollIntervalMs: 1,
69
+ });
70
+ expect(tx.hash).toBe("tx");
71
+ expect(tx.tickNumber).toBe(10n);
72
+ });
73
+
74
+ it("throws TxNotFoundError if target tick is reached but tx stays 404", async () => {
75
+ const fetch: FetchLike = async (...args) => {
76
+ const url = new URL(getUrl(args[0]));
77
+ const method = getMethod(args[0], args[1]);
78
+ if (method === "GET" && url.pathname === "/query/v1/getLastProcessedTick") {
79
+ return Response.json({ tickNumber: 10, epoch: 0, intervalInitialTick: 0 });
80
+ }
81
+ if (method === "POST" && url.pathname === "/query/v1/getTransactionByHash") {
82
+ return new Response("not found", { status: 404 });
83
+ }
84
+ return new Response("not found", { status: 404 });
85
+ };
86
+
87
+ const rpc = createRpcClient({ baseUrl: "https://example.test", fetch });
88
+ const confirm = createTxConfirmationHelpers({
89
+ rpc,
90
+ defaultTimeoutMs: 20,
91
+ defaultPollIntervalMs: 1,
92
+ });
93
+ await expect(
94
+ confirm.waitForConfirmation({ txId: "tx", targetTick: 10 }),
95
+ ).rejects.toBeInstanceOf(TxNotFoundError);
96
+ });
97
+
98
+ it("throws TxConfirmationTimeoutError if lastProcessedTick never reaches target", async () => {
99
+ const fetch: FetchLike = async (...args) => {
100
+ const url = new URL(getUrl(args[0]));
101
+ const method = getMethod(args[0], args[1]);
102
+ if (method === "GET" && url.pathname === "/query/v1/getLastProcessedTick") {
103
+ return Response.json({ tickNumber: 0, epoch: 0, intervalInitialTick: 0 });
104
+ }
105
+ if (method === "POST" && url.pathname === "/query/v1/getTransactionByHash") {
106
+ return new Response("not found", { status: 404 });
107
+ }
108
+ return new Response("not found", { status: 404 });
109
+ };
110
+
111
+ const rpc = createRpcClient({ baseUrl: "https://example.test", fetch });
112
+ const confirm = createTxConfirmationHelpers({
113
+ rpc,
114
+ defaultTimeoutMs: 20,
115
+ defaultPollIntervalMs: 1,
116
+ });
117
+ await expect(
118
+ confirm.waitForConfirmation({ txId: "tx", targetTick: 10 }),
119
+ ).rejects.toBeInstanceOf(TxConfirmationTimeoutError);
120
+ });
121
+ });
122
+
123
+ function getUrl(input: Parameters<typeof fetch>[0]): string {
124
+ if (typeof input === "string") return input;
125
+ if (input instanceof URL) return input.toString();
126
+ return input.url;
127
+ }
128
+
129
+ function getMethod(input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]): string {
130
+ if (init?.method) return init.method;
131
+ if (input instanceof Request) return input.method;
132
+ return "GET";
133
+ }
134
+
135
+ function readJsonBody(
136
+ input: Parameters<typeof fetch>[0],
137
+ init: Parameters<typeof fetch>[1],
138
+ ): Record<string, unknown> {
139
+ if (input instanceof Request) {
140
+ // Our client doesn't use this path in tests, but keep it safe.
141
+ throw new Error("Unexpected Request body");
142
+ }
143
+ const body = init?.body;
144
+ if (typeof body === "string") {
145
+ return JSON.parse(body) as Record<string, unknown>;
146
+ }
147
+ if (!body) return {};
148
+ throw new Error("Unsupported body type");
149
+ }