@koralabs/kora-labs-common 6.13.3 → 6.14.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.
package/chain/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './transport/fetchProviderJson';
2
+ export * from './transport/rateLimit';
2
3
  export * from './failover/interfaces';
3
4
  export * from './failover/ChainProviderFailover';
4
5
  export * from './cache/ShortTermCache';
package/chain/index.js CHANGED
@@ -18,6 +18,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
18
18
  // NOT re-exported from the root index — this is a server-side chain-access layer and pulls Node-only
19
19
  // deps. Keeping it off the root barrel preserves the isomorphic root (same rule the `aws` module follows).
20
20
  __exportStar(require("./transport/fetchProviderJson"), exports);
21
+ __exportStar(require("./transport/rateLimit"), exports);
21
22
  __exportStar(require("./failover/interfaces"), exports);
22
23
  __exportStar(require("./failover/ChainProviderFailover"), exports);
23
24
  __exportStar(require("./cache/ShortTermCache"), exports);
@@ -1,26 +1,30 @@
1
+ import { HeaderReader } from './rateLimit';
1
2
  export type ProviderName = 'Koios' | 'Blockfrost';
2
3
  type ProviderFetcher = (url: string, init?: {
3
4
  method?: string;
4
5
  headers?: Record<string, string>;
5
- body?: string;
6
+ body?: string | Uint8Array;
6
7
  }) => Promise<{
7
8
  ok: boolean;
8
9
  status: number;
9
10
  statusText: string;
10
11
  text: () => Promise<string>;
12
+ headers?: HeaderReader;
11
13
  }>;
12
14
  export interface ProviderRequestOptions {
13
15
  provider: ProviderName;
14
16
  url: string;
15
17
  method?: string;
16
18
  headers?: Record<string, string>;
17
- body?: string;
19
+ body?: string | Uint8Array;
18
20
  maxRetries?: number;
19
21
  retryBaseDelayMs?: number;
20
22
  maxRps?: number;
21
23
  rateLimitKey?: string;
24
+ /** Longest server-stated rate-limit wait to sit out in-process; longer waits throw RateLimitedError. */
25
+ maxRateLimitWaitMs?: number;
22
26
  fetcher?: ProviderFetcher;
23
27
  }
24
28
  export declare const isRetriableError: (provider: ProviderName, error: any) => boolean;
25
- export declare const fetchProviderJson: <T>({ provider, url, method, headers, body, maxRetries, retryBaseDelayMs, maxRps, rateLimitKey, fetcher }: ProviderRequestOptions) => Promise<T>;
29
+ export declare const fetchProviderJson: <T>({ provider, url, method, headers, body, maxRetries, retryBaseDelayMs, maxRps, rateLimitKey, maxRateLimitWaitMs, fetcher }: ProviderRequestOptions) => Promise<T>;
26
30
  export {};
@@ -2,20 +2,23 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.fetchProviderJson = exports.isRetriableError = void 0;
4
4
  const utils_1 = require("../../utils");
5
+ const rateLimit_1 = require("./rateLimit");
5
6
  const DEFAULT_MAX_RPS = 5;
6
7
  const DEFAULT_MAX_RETRIES = 2;
7
8
  const DEFAULT_RETRY_BASE_DELAY_MS = 500;
8
- const RETRIABLE_STATUS_CODES = new Set([403, 408, 425, 429, 500, 502, 503, 504]);
9
+ const DEFAULT_MAX_RATE_LIMIT_WAIT_MS = 30000;
10
+ const MAX_RATE_LIMIT_WAITS = 3;
11
+ // 429 is deliberately absent: a rate limit is retried only after the wait the server states (see
12
+ // fetchProviderJson), never on a blind backoff.
13
+ const RETRIABLE_STATUS_CODES = new Set([403, 408, 425, 500, 502, 503, 504]);
9
14
  const RETRIABLE_MESSAGE_SNIPPETS = [
10
15
  'terminated',
11
16
  'socket',
12
17
  'econnreset',
13
18
  'fetch failed',
14
19
  'gateway timeout',
15
- 'too many requests',
16
20
  'payload too large',
17
21
  'timed out acquiring connection from connection pool',
18
- 'rate limit',
19
22
  'service unavailable',
20
23
  'temporarily unavailable'
21
24
  ];
@@ -126,16 +129,25 @@ const isRetriableError = (provider, error) => {
126
129
  };
127
130
  exports.isRetriableError = isRetriableError;
128
131
  const defaultFetch = (url, init) => globalThis.fetch(url, init);
129
- const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body, maxRetries = DEFAULT_MAX_RETRIES, retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, maxRps = DEFAULT_MAX_RPS, rateLimitKey = provider, fetcher = defaultFetch }) => {
132
+ const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body, maxRetries = DEFAULT_MAX_RETRIES, retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, maxRps = DEFAULT_MAX_RPS, rateLimitKey = provider, maxRateLimitWaitMs = DEFAULT_MAX_RATE_LIMIT_WAIT_MS, fetcher = defaultFetch }) => {
130
133
  var _a, _b, _c, _d;
131
134
  let attempt = 0;
135
+ let rateLimitWaits = 0;
132
136
  // Retry loop: exits only via return (success) or throw (non-retriable / retries exhausted).
133
137
  // eslint-disable-next-line no-constant-condition
134
138
  while (true) {
139
+ // A wait recorded by ANY earlier call on this key (this process) is honored before calling again.
140
+ await (0, rateLimit_1.waitForRateLimit)(rateLimitKey, maxRateLimitWaitMs);
141
+ let rateLimited;
135
142
  try {
136
143
  const response = await runRateLimitedTask(rateLimitKey, maxRps, () => fetcher(url, { method, headers, body }));
137
144
  const responseText = await response.text();
138
145
  const { value: parsed, parseError } = parseJson(responseText);
146
+ if (response.ok && isQuotaExhausted(response.headers)) {
147
+ const waitMs = (0, rateLimit_1.statedWaitMs)(response.headers, undefined);
148
+ if (waitMs !== null)
149
+ (0, rateLimit_1.recordRateLimit)(rateLimitKey, waitMs);
150
+ }
139
151
  if (parseError && responseText) {
140
152
  throw createProviderError({
141
153
  provider,
@@ -149,9 +161,18 @@ const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body,
149
161
  if (!response.ok || payloadError) {
150
162
  const status = Number((_b = (_a = payloadError === null || payloadError === void 0 ? void 0 : payloadError.status_code) !== null && _a !== void 0 ? _a : payloadError === null || payloadError === void 0 ? void 0 : payloadError.status) !== null && _b !== void 0 ? _b : response.status);
151
163
  const statusText = response.statusText || `${(_d = (_c = payloadError === null || payloadError === void 0 ? void 0 : payloadError.error) !== null && _c !== void 0 ? _c : payloadError === null || payloadError === void 0 ? void 0 : payloadError.message) !== null && _d !== void 0 ? _d : 'Request failed'}`;
152
- throw createProviderError({ provider, status, statusText, payload: payloadError !== null && payloadError !== void 0 ? payloadError : parsed, responseText });
164
+ const error = createProviderError({ provider, status, statusText, payload: payloadError !== null && payloadError !== void 0 ? payloadError : parsed, responseText });
165
+ const waitMs = (0, rateLimit_1.statedWaitMs)(response.headers, parsed);
166
+ if (status === 429 || waitMs !== null) {
167
+ rateLimited = { waitMs, error };
168
+ }
169
+ else {
170
+ throw error;
171
+ }
172
+ }
173
+ else {
174
+ return parsed;
153
175
  }
154
- return parsed;
155
176
  }
156
177
  catch (error) {
157
178
  if (attempt >= maxRetries || !(0, exports.isRetriableError)(provider, error)) {
@@ -159,7 +180,18 @@ const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body,
159
180
  }
160
181
  attempt++;
161
182
  await delay(retryBaseDelayMs * attempt);
183
+ continue;
184
+ }
185
+ // Rate limited: never call again before the stated time; with no stated time, stop.
186
+ const { waitMs, error } = rateLimited;
187
+ if (waitMs === null)
188
+ throw error;
189
+ (0, rateLimit_1.recordRateLimit)(rateLimitKey, waitMs);
190
+ // Sitting out a stated wait is not a blind retry, so it does not spend `maxRetries` — but it is bounded.
191
+ if (++rateLimitWaits > MAX_RATE_LIMIT_WAITS || waitMs > maxRateLimitWaitMs) {
192
+ throw new rateLimit_1.RateLimitedError(rateLimitKey, waitMs, error.message);
162
193
  }
163
194
  }
164
195
  };
165
196
  exports.fetchProviderJson = fetchProviderJson;
197
+ const isQuotaExhausted = (headers) => { var _a, _b; return ((_a = headers === null || headers === void 0 ? void 0 : headers.get('x-ratelimit-remaining')) === null || _a === void 0 ? void 0 : _a.trim()) === '0' || ((_b = headers === null || headers === void 0 ? void 0 : headers.get('ratelimit-remaining')) === null || _b === void 0 ? void 0 : _b.trim()) === '0'; };
@@ -0,0 +1,21 @@
1
+ export interface HeaderReader {
2
+ get(name: string): string | null | undefined;
3
+ }
4
+ export declare class RateLimitedError extends Error {
5
+ readonly key: string;
6
+ readonly retryAfterMs: number;
7
+ constructor(key: string, retryAfterMs: number, detail?: string);
8
+ }
9
+ /**
10
+ * The wait (ms) a response asks for, or null when it states none. Covers Retry-After (seconds or
11
+ * date), X-RateLimit-Reset-After, RateLimit-Reset, X-RateLimit-Remaining: 0 + X-RateLimit-Reset,
12
+ * and waits written in the body (Discord `retry_after`, Google `RetryInfo.retryDelay`).
13
+ */
14
+ export declare const statedWaitMs: (headers: HeaderReader | undefined, body: unknown, now?: number) => number | null;
15
+ /** Record that `key` must not be called for `waitMs` (only ever extends an existing block). */
16
+ export declare const recordRateLimit: (key: string, waitMs: number, now?: number) => void;
17
+ export declare const rateLimitWaitRemainingMs: (key: string, now?: number) => number;
18
+ /** Wait out a recorded limit before calling `key`; throw (returning the wait) when it exceeds `maxWaitMs`. */
19
+ export declare const waitForRateLimit: (key: string, maxWaitMs: number) => Promise<void>;
20
+ /** Test seam: forget every recorded limit. */
21
+ export declare const resetRateLimits: () => void;
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ // Rate-limit signals are law: every provider call honors the wait a server states, remembers it for
3
+ // the whole process (so other callers don't hit the same limit), and never retries early. When the
4
+ // stated wait is too long to hold the caller, it stops and hands the wait back (RateLimitedError).
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resetRateLimits = exports.waitForRateLimit = exports.rateLimitWaitRemainingMs = exports.recordRateLimit = exports.statedWaitMs = exports.RateLimitedError = void 0;
7
+ class RateLimitedError extends Error {
8
+ constructor(key, retryAfterMs, detail = '') {
9
+ super(`Rate limited by ${key}: retry after ${Math.ceil(retryAfterMs / 1000)}s${detail ? ` (${detail})` : ''}`);
10
+ this.key = key;
11
+ this.retryAfterMs = retryAfterMs;
12
+ this.name = 'RateLimitedError';
13
+ }
14
+ }
15
+ exports.RateLimitedError = RateLimitedError;
16
+ const blockedUntilByKey = new Map();
17
+ const toMs = (seconds) => Math.max(0, Math.ceil(seconds * 1000));
18
+ const parseSeconds = (raw) => {
19
+ if (raw === null || raw === undefined || raw.trim() === '')
20
+ return null;
21
+ const value = Number(raw.trim().replace(/s$/i, ''));
22
+ return Number.isFinite(value) ? value : null;
23
+ };
24
+ /** `Retry-After` is either delta-seconds or an HTTP date. */
25
+ const parseRetryAfter = (raw, now) => {
26
+ const seconds = parseSeconds(raw);
27
+ if (seconds !== null)
28
+ return toMs(seconds);
29
+ if (!raw)
30
+ return null;
31
+ const date = Date.parse(raw);
32
+ return Number.isFinite(date) ? Math.max(0, date - now) : null;
33
+ };
34
+ /** `X-RateLimit-Reset` is an epoch (seconds or ms) on most APIs, a delta on a few; disambiguate by size. */
35
+ const parseReset = (raw, now) => {
36
+ const value = parseSeconds(raw);
37
+ if (value === null)
38
+ return null;
39
+ if (value > 1e12)
40
+ return Math.max(0, value - now); // epoch ms
41
+ if (value > 1e9)
42
+ return Math.max(0, value * 1000 - now); // epoch seconds
43
+ return toMs(value); // delta seconds
44
+ };
45
+ const bodyWaitMs = (body) => {
46
+ var _a, _b;
47
+ if (!body || typeof body !== 'object')
48
+ return null;
49
+ const record = body;
50
+ // Discord: { retry_after: seconds }
51
+ const retryAfter = parseSeconds(record.retry_after === undefined ? undefined : String(record.retry_after));
52
+ if (retryAfter !== null)
53
+ return toMs(retryAfter);
54
+ // Google: { error: { details: [{ '@type': '...RetryInfo', retryDelay: '1.5s' }] } }
55
+ const details = (_b = (_a = record.error) === null || _a === void 0 ? void 0 : _a.details) !== null && _b !== void 0 ? _b : [];
56
+ const retryInfo = details.find((d) => typeof (d === null || d === void 0 ? void 0 : d['@type']) === 'string' && d['@type'].endsWith('RetryInfo'));
57
+ const delay = parseSeconds(retryInfo === null || retryInfo === void 0 ? void 0 : retryInfo.retryDelay);
58
+ return delay === null ? null : toMs(delay);
59
+ };
60
+ /**
61
+ * The wait (ms) a response asks for, or null when it states none. Covers Retry-After (seconds or
62
+ * date), X-RateLimit-Reset-After, RateLimit-Reset, X-RateLimit-Remaining: 0 + X-RateLimit-Reset,
63
+ * and waits written in the body (Discord `retry_after`, Google `RetryInfo.retryDelay`).
64
+ */
65
+ const statedWaitMs = (headers, body, now = Date.now()) => {
66
+ var _a;
67
+ const header = (name) => { var _a; return (_a = headers === null || headers === void 0 ? void 0 : headers.get(name)) !== null && _a !== void 0 ? _a : null; };
68
+ const candidates = [
69
+ parseRetryAfter(header('retry-after'), now),
70
+ parseSeconds(header('x-ratelimit-reset-after')) === null ? null : toMs(parseSeconds(header('x-ratelimit-reset-after'))),
71
+ parseSeconds(header('ratelimit-reset')) === null ? null : toMs(parseSeconds(header('ratelimit-reset'))),
72
+ ((_a = header('x-ratelimit-remaining')) === null || _a === void 0 ? void 0 : _a.trim()) === '0' ? parseReset(header('x-ratelimit-reset'), now) : null,
73
+ bodyWaitMs(body)
74
+ ].filter((ms) => ms !== null);
75
+ return candidates.length > 0 ? Math.max(...candidates) : null;
76
+ };
77
+ exports.statedWaitMs = statedWaitMs;
78
+ /** Record that `key` must not be called for `waitMs` (only ever extends an existing block). */
79
+ const recordRateLimit = (key, waitMs, now = Date.now()) => {
80
+ var _a;
81
+ const until = now + waitMs;
82
+ if (until > ((_a = blockedUntilByKey.get(key)) !== null && _a !== void 0 ? _a : 0))
83
+ blockedUntilByKey.set(key, until);
84
+ };
85
+ exports.recordRateLimit = recordRateLimit;
86
+ const rateLimitWaitRemainingMs = (key, now = Date.now()) => { var _a; return Math.max(0, ((_a = blockedUntilByKey.get(key)) !== null && _a !== void 0 ? _a : 0) - now); };
87
+ exports.rateLimitWaitRemainingMs = rateLimitWaitRemainingMs;
88
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
89
+ /** Wait out a recorded limit before calling `key`; throw (returning the wait) when it exceeds `maxWaitMs`. */
90
+ const waitForRateLimit = async (key, maxWaitMs) => {
91
+ const remaining = (0, exports.rateLimitWaitRemainingMs)(key);
92
+ if (remaining === 0)
93
+ return;
94
+ if (remaining > maxWaitMs)
95
+ throw new RateLimitedError(key, remaining);
96
+ await sleep(remaining);
97
+ };
98
+ exports.waitForRateLimit = waitForRateLimit;
99
+ /** Test seam: forget every recorded limit. */
100
+ const resetRateLimits = () => blockedUntilByKey.clear();
101
+ exports.resetRateLimits = resetRateLimits;
@@ -0,0 +1,49 @@
1
+ export interface CronLockTopology {
2
+ network: string;
3
+ nodeCode: string;
4
+ mintNodes: string[];
5
+ localDataCenter: string;
6
+ contactPoints: string[];
7
+ }
8
+ export interface CqlExecutor {
9
+ execute(query: string, params?: unknown[], options?: Record<string, unknown>): Promise<{
10
+ rows: unknown[];
11
+ }>;
12
+ }
13
+ export type AcquireResult = {
14
+ status: 'acquired';
15
+ token: string;
16
+ } | {
17
+ status: 'held';
18
+ } | {
19
+ status: 'peerUnavailable';
20
+ token: string;
21
+ message: string;
22
+ } | {
23
+ status: 'unavailable';
24
+ message: string;
25
+ };
26
+ /** A missing or incoherent topology is unsafe: never infer a one-node cluster. */
27
+ export declare const resolveTopology: (env?: NodeJS.ProcessEnv) => CronLockTopology;
28
+ /**
29
+ * Lease (TTL) of a held lock: how long a crashed holder blocks the next run. Callers pass the lease
30
+ * their job needs (it must exceed the job's maximum run time); the default honors
31
+ * STALE_EXECUTING_CRON_LOCK_MS, else 30 minutes.
32
+ */
33
+ export declare const leaseMs: (override?: number) => number;
34
+ export declare const parseOffline: (raw: string | undefined | null) => string[];
35
+ type Mode = 'serial' | 'localSerial' | 'standDown';
36
+ export declare const resolveMode: (offline: string[], topology: CronLockTopology) => Mode;
37
+ /** Only an UNAVAILABLE response proves that no SERIAL proposal was accepted. */
38
+ export declare const isPeerUnavailableError: (error: unknown) => boolean;
39
+ /**
40
+ * The directly testable LWT operation. Each invocation owns a unique token, so
41
+ * concurrent ticks on one node cannot both treat the node identity as ownership.
42
+ */
43
+ export declare const acquireWithExecutor: (executor: CqlExecutor, name: string, offline: string | null | undefined, topology: CronLockTopology, token?: string, lease?: number) => Promise<AcquireResult>;
44
+ export declare const acquire: (name: string, offline?: string | null, options?: {
45
+ leaseMs?: number;
46
+ }) => Promise<AcquireResult>;
47
+ /** Release only the exact acquisition token owned by this invocation. */
48
+ export declare const release: (name: string, token: string, offline?: string | null) => Promise<void>;
49
+ export {};
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.release = exports.acquire = exports.acquireWithExecutor = exports.isPeerUnavailableError = exports.resolveMode = exports.parseOffline = exports.leaseMs = exports.resolveTopology = void 0;
4
+ /**
5
+ * Cross-datacenter cron mutex for the self-hosted, multi-DC Scylla topology.
6
+ * (Moved from minting.handle.me src/helpers/cronLock/cqlCronLock.ts @ 6ecb9c3 so every box cron
7
+ * — minting.handle.me, hal-minting-engine — shares one implementation.)
8
+ *
9
+ * Minting runs active/active across boxes, each in its own Scylla datacenter with
10
+ * RF=1 per DC. Alternator conditional writes use LOCAL_SERIAL, so each DC can win
11
+ * independently. CQL SERIAL is the cross-DC mutex.
12
+ *
13
+ * Operators declare unavailable boxes through the offline set:
14
+ * - all boxes online: SERIAL / QUORUM;
15
+ * - this box offline: stand down;
16
+ * - this box is the only declared-online box: LOCAL_SERIAL / LOCAL_QUORUM;
17
+ * - an undeclared peer outage: the caller normally fails closed. StateData may
18
+ * use its DC-local fallback only for mintPaidSessions and only after a definite
19
+ * CQL UNAVAILABLE response (never after a timeout or unknown failure).
20
+ *
21
+ * The row TTL recovers crashed holders. Unique per-invocation owner tokens prevent
22
+ * overlapping ticks on one box from mistaking node identity for lock ownership.
23
+ */
24
+ const node_crypto_1 = require("node:crypto");
25
+ const cassandra_driver_1 = require("cassandra-driver");
26
+ const logger_1 = require("../logger");
27
+ const KEYSPACE = 'kora_locks';
28
+ const LOCK_TABLE = 'cron_lock';
29
+ const DEFAULT_LEASE_MS = 30 * 60 * 1000;
30
+ const required = (env, name) => {
31
+ var _a;
32
+ const value = (_a = env[name]) === null || _a === void 0 ? void 0 : _a.trim();
33
+ if (!value)
34
+ throw new Error(`${name} is required for cron-lock topology`);
35
+ return value;
36
+ };
37
+ const parseList = (value) => [...new Set(value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean))];
38
+ /** A missing or incoherent topology is unsafe: never infer a one-node cluster. */
39
+ const resolveTopology = (env = process.env) => {
40
+ const network = required(env, 'NETWORK').toLowerCase();
41
+ const nodeCode = required(env, 'KORA_NODE_CODE').toLowerCase();
42
+ const mintNodes = parseList(required(env, 'KORA_MINT_NODES'));
43
+ const localDataCenter = required(env, 'KORA_SCYLLA_DC');
44
+ const contactPoints = parseList(required(env, 'KORA_CQL_CONTACT_POINTS'));
45
+ if (!mintNodes.includes(nodeCode)) {
46
+ throw new Error(`KORA_NODE_CODE '${nodeCode}' is not present in KORA_MINT_NODES`);
47
+ }
48
+ if (contactPoints.length === 0) {
49
+ throw new Error('KORA_CQL_CONTACT_POINTS must contain at least one contact point');
50
+ }
51
+ return { network, nodeCode, mintNodes, localDataCenter, contactPoints };
52
+ };
53
+ exports.resolveTopology = resolveTopology;
54
+ /**
55
+ * Lease (TTL) of a held lock: how long a crashed holder blocks the next run. Callers pass the lease
56
+ * their job needs (it must exceed the job's maximum run time); the default honors
57
+ * STALE_EXECUTING_CRON_LOCK_MS, else 30 minutes.
58
+ */
59
+ const leaseMs = (override) => {
60
+ if (override !== undefined) {
61
+ if (!Number.isFinite(override) || override <= 0)
62
+ throw new Error(`Invalid cron-lock lease ${override}`);
63
+ return override;
64
+ }
65
+ const parsed = Number(process.env.STALE_EXECUTING_CRON_LOCK_MS);
66
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LEASE_MS;
67
+ };
68
+ exports.leaseMs = leaseMs;
69
+ const leaseSeconds = (override) => Math.ceil((0, exports.leaseMs)(override) / 1000);
70
+ const scopeName = (name, topology) => `${topology.network}:${name}`;
71
+ let client = null;
72
+ let clientTopologyKey = '';
73
+ let ensured = false;
74
+ const getClient = (topology) => {
75
+ const topologyKey = JSON.stringify([topology.contactPoints, topology.localDataCenter]);
76
+ if (!client || clientTopologyKey !== topologyKey) {
77
+ client = new cassandra_driver_1.Client({
78
+ contactPoints: topology.contactPoints,
79
+ localDataCenter: topology.localDataCenter,
80
+ socketOptions: { connectTimeout: 8000, readTimeout: 12000 }
81
+ });
82
+ clientTopologyKey = topologyKey;
83
+ ensured = false;
84
+ }
85
+ return client;
86
+ };
87
+ const ensureSchema = async (executor) => {
88
+ if (ensured)
89
+ return;
90
+ await executor.execute(`CREATE KEYSPACE IF NOT EXISTS ${KEYSPACE} WITH replication = ` +
91
+ `{'class':'NetworkTopologyStrategy','kora-dc1':1,'kora-dc2':1}`);
92
+ await executor.execute(`CREATE TABLE IF NOT EXISTS ${KEYSPACE}.${LOCK_TABLE} (name text PRIMARY KEY, owner text, acquired_at timestamp)`);
93
+ ensured = true;
94
+ };
95
+ const parseOffline = (raw) => {
96
+ const value = (raw !== null && raw !== void 0 ? raw : '').trim().toLowerCase();
97
+ if (!value || value === 'none')
98
+ return [];
99
+ return parseList(value);
100
+ };
101
+ exports.parseOffline = parseOffline;
102
+ const resolveMode = (offline, topology) => {
103
+ const unknownNodes = offline.filter((node) => !topology.mintNodes.includes(node));
104
+ if (unknownNodes.length > 0) {
105
+ throw new Error(`Offline set contains unknown mint node(s): ${unknownNodes.join(',')}`);
106
+ }
107
+ if (offline.includes(topology.nodeCode))
108
+ return 'standDown';
109
+ const online = topology.mintNodes.filter((node) => !offline.includes(node));
110
+ return online.length === 1 ? 'localSerial' : 'serial';
111
+ };
112
+ exports.resolveMode = resolveMode;
113
+ const createToken = (topology) => `${topology.nodeCode}:${(0, node_crypto_1.randomUUID)()}`;
114
+ /** Only an UNAVAILABLE response proves that no SERIAL proposal was accepted. */
115
+ const isPeerUnavailableError = (error) => error instanceof cassandra_driver_1.errors.ResponseError && error.code === cassandra_driver_1.types.responseErrorCodes.unavailableException;
116
+ exports.isPeerUnavailableError = isPeerUnavailableError;
117
+ /**
118
+ * The directly testable LWT operation. Each invocation owns a unique token, so
119
+ * concurrent ticks on one node cannot both treat the node identity as ownership.
120
+ */
121
+ const acquireWithExecutor = async (executor, name, offline, topology, token = createToken(topology), lease) => {
122
+ const mode = (0, exports.resolveMode)((0, exports.parseOffline)(offline), topology);
123
+ if (mode === 'standDown')
124
+ return { status: 'held' };
125
+ const serialConsistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localSerial : cassandra_driver_1.types.consistencies.serial;
126
+ const consistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localQuorum : cassandra_driver_1.types.consistencies.quorum;
127
+ const key = scopeName(name, topology);
128
+ try {
129
+ const result = await executor.execute(`INSERT INTO ${KEYSPACE}.${LOCK_TABLE} (name, owner, acquired_at) VALUES (?, ?, ?) IF NOT EXISTS USING TTL ${leaseSeconds(lease)}`, [key, token, new Date()], { prepare: true, serialConsistency, consistency });
130
+ const row = result.rows[0];
131
+ if ((row === null || row === void 0 ? void 0 : row['[applied]']) === true)
132
+ return { status: 'acquired', token };
133
+ if ((row === null || row === void 0 ? void 0 : row.owner) !== token)
134
+ return { status: 'held' };
135
+ const refresh = await executor.execute(`UPDATE ${KEYSPACE}.${LOCK_TABLE} USING TTL ${leaseSeconds(lease)} SET owner = ?, acquired_at = ? WHERE name = ? IF owner = ?`, [token, new Date(), key, token], { prepare: true, serialConsistency, consistency });
136
+ const refreshRow = refresh.rows[0];
137
+ return (refreshRow === null || refreshRow === void 0 ? void 0 : refreshRow['[applied]']) === true ? { status: 'acquired', token } : { status: 'held' };
138
+ }
139
+ catch (error) {
140
+ const message = error instanceof Error ? error.message : String(error);
141
+ if (mode === 'serial' && (0, exports.isPeerUnavailableError)(error)) {
142
+ return { status: 'peerUnavailable', token, message };
143
+ }
144
+ return { status: 'unavailable', message };
145
+ }
146
+ };
147
+ exports.acquireWithExecutor = acquireWithExecutor;
148
+ const acquire = async (name, offline, options = {}) => {
149
+ try {
150
+ const topology = (0, exports.resolveTopology)();
151
+ const executor = getClient(topology);
152
+ await ensureSchema(executor);
153
+ const result = await (0, exports.acquireWithExecutor)(executor, name, offline, topology, undefined, options.leaseMs);
154
+ if (result.status === 'peerUnavailable') {
155
+ logger_1.Logger.log({
156
+ message: `cron lock '${name}' SERIAL unavailable because the configured peer quorum is unavailable: ${result.message}`,
157
+ event: 'cqlCronLock.acquire.peerUnavailable',
158
+ category: logger_1.LogCategory.WARN
159
+ });
160
+ }
161
+ else if (result.status === 'unavailable') {
162
+ logger_1.Logger.log({
163
+ message: `cron lock '${name}' failed without a positively identified peer-unavailable response: ${result.message}`,
164
+ event: 'cqlCronLock.acquire.unavailable',
165
+ category: logger_1.LogCategory.WARN
166
+ });
167
+ }
168
+ return result;
169
+ }
170
+ catch (error) {
171
+ logger_1.Logger.log({
172
+ message: `cron lock '${name}' is unavailable: ${error.message}`,
173
+ event: 'cqlCronLock.acquire.unavailable',
174
+ category: logger_1.LogCategory.WARN
175
+ });
176
+ return { status: 'unavailable', message: error.message };
177
+ }
178
+ };
179
+ exports.acquire = acquire;
180
+ /** Release only the exact acquisition token owned by this invocation. */
181
+ const release = async (name, token, offline) => {
182
+ try {
183
+ const topology = (0, exports.resolveTopology)();
184
+ const mode = (0, exports.resolveMode)((0, exports.parseOffline)(offline), topology);
185
+ if (mode === 'standDown')
186
+ return;
187
+ const serialConsistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localSerial : cassandra_driver_1.types.consistencies.serial;
188
+ const consistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localQuorum : cassandra_driver_1.types.consistencies.quorum;
189
+ await getClient(topology).execute(`DELETE FROM ${KEYSPACE}.${LOCK_TABLE} WHERE name = ? IF owner = ?`, [scopeName(name, topology), token], { prepare: true, serialConsistency, consistency });
190
+ }
191
+ catch (error) {
192
+ logger_1.Logger.log({
193
+ message: `cron lock '${name}' release best-effort failed (TTL will reclaim): ${error.message}`,
194
+ event: 'cqlCronLock.release.softfail',
195
+ category: logger_1.LogCategory.INFO
196
+ });
197
+ }
198
+ };
199
+ exports.release = release;
@@ -0,0 +1 @@
1
+ export * from './cqlCronLock';
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ // Subpath module: `@koralabs/kora-labs-common/cronLock`. Node-only (cassandra-driver, optional peer
18
+ // dependency). NOT re-exported from the root index.
19
+ __exportStar(require("./cqlCronLock"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koralabs/kora-labs-common",
3
- "version": "6.13.3",
3
+ "version": "6.14.0",
4
4
  "description": "Kora Labs Common Utilities",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -40,7 +40,7 @@
40
40
  "@aiken-lang/merkle-patricia-forestry": "^1.2.0",
41
41
  "@aws-sdk/client-dynamodb": "^3.549.0",
42
42
  "@aws-sdk/lib-dynamodb": "^3.549.0",
43
- "@cardano-sdk/core": "^0.46.15",
43
+ "@cardano-sdk/core": "^0.47.0",
44
44
  "@cardano-sdk/crypto": "^0.4.7",
45
45
  "@emurgo/cardano-message-signing-nodejs": "^1.1.0",
46
46
  "@types/jest": "^30.0.0",
@@ -49,6 +49,7 @@
49
49
  "@typescript-eslint/eslint-plugin": "^8.67.0",
50
50
  "@typescript-eslint/parser": "^8.67.0",
51
51
  "bip39": "^3.1.0",
52
+ "cassandra-driver": "^4.10.0",
52
53
  "eslint": "^8.57.1",
53
54
  "jest": "^30.4.2",
54
55
  "ts-jest": "^29.4.11",
@@ -72,10 +73,11 @@
72
73
  },
73
74
  "peerDependencies": {
74
75
  "@aiken-lang/merkle-patricia-forestry": "^1.2.0",
75
- "@cardano-sdk/core": "^0.46.12",
76
+ "@cardano-sdk/core": "^0.46.12 || ^0.47.0",
76
77
  "@cardano-sdk/crypto": "^0.4.5",
78
+ "@emurgo/cardano-message-signing-nodejs": "^1.1.0",
77
79
  "bip39": "^3.1.0",
78
- "@emurgo/cardano-message-signing-nodejs": "^1.1.0"
80
+ "cassandra-driver": "^4.9.0"
79
81
  },
80
82
  "peerDependenciesMeta": {
81
83
  "@aiken-lang/merkle-patricia-forestry": {
@@ -92,6 +94,9 @@
92
94
  },
93
95
  "@emurgo/cardano-message-signing-nodejs": {
94
96
  "optional": true
97
+ },
98
+ "cassandra-driver": {
99
+ "optional": true
95
100
  }
96
101
  }
97
102
  }
@@ -0,0 +1,66 @@
1
+ import { Cardano } from '@cardano-sdk/core';
2
+ import { ProviderRequestOptions } from '../chain/transport/fetchProviderJson';
3
+ import { ExUnits } from './fees';
4
+ import { RedeemerKey, ScriptTxProtocolParameters } from './scriptTx';
5
+ type BlockfrostAmount = {
6
+ unit: string;
7
+ quantity: string;
8
+ }[];
9
+ export interface BlockfrostScript {
10
+ hash: string;
11
+ language: Cardano.PlutusLanguageVersion | 'native';
12
+ /** Serialized size (the ledger's reference-script size). */
13
+ size: number;
14
+ script?: Cardano.Script;
15
+ }
16
+ export declare class BlockfrostEvaluationError extends Error {
17
+ readonly failure: unknown;
18
+ constructor(failure: unknown);
19
+ }
20
+ export declare const valueFromBlockfrost: (amount: BlockfrostAmount) => Cardano.Value;
21
+ /** Additional-UTxO shape Blockfrost's evaluator accepts: Ogmios value `{ ada: { lovelace }, [policy]: { [asset]: qty } }`. */
22
+ export declare const toOgmiosUtxo: ([txIn, txOut]: Cardano.Utxo) => ({
23
+ txId: any;
24
+ index: number;
25
+ } | {
26
+ datumHash?: any;
27
+ datum?: any;
28
+ address: Tagged<string, T>;
29
+ value: Record<string, Record<string, number>>;
30
+ txId?: undefined;
31
+ index?: undefined;
32
+ })[];
33
+ export declare class BlockfrostTxClient {
34
+ private readonly config;
35
+ private readonly scripts;
36
+ private readonly host;
37
+ constructor(config: {
38
+ network: string;
39
+ blockfrostApiKey: string;
40
+ fetcher?: ProviderRequestOptions['fetcher'];
41
+ });
42
+ private request;
43
+ private getOrNull;
44
+ getProtocolParameters(): Promise<ScriptTxProtocolParameters>;
45
+ getLatestBlock(): Promise<{
46
+ slot: number;
47
+ time: number;
48
+ hash: string;
49
+ height: number;
50
+ }>;
51
+ /** Slot of a POSIX time (ms), from the tip (1-second slots since Shelley on every network). */
52
+ slotAt(posixMs: number): Promise<number>;
53
+ getScript(hash: string): Promise<BlockfrostScript>;
54
+ private toUtxo;
55
+ /** Every UTxO at `address` (paginated). */
56
+ getAddressUtxos(address: string): Promise<Cardano.Utxo[]>;
57
+ /** UTxOs at `address` holding `unit`. */
58
+ getAddressAssetUtxos(address: string, unit: string): Promise<Cardano.Utxo[]>;
59
+ /** The UTxO `txHash#index`; throws `already spent` when it has been consumed. */
60
+ getUtxo(ref: string): Promise<Cardano.Utxo>;
61
+ /** Node (Ogmios) evaluation of every redeemer; `additionalUtxos` supplies inputs not yet on chain. */
62
+ evaluateTx(cbor: string, additionalUtxos?: Cardano.Utxo[]): Promise<Map<RedeemerKey, ExUnits>>;
63
+ /** Submit a signed tx; returns its hash. */
64
+ submitTx(cbor: string): Promise<string>;
65
+ }
66
+ export {};
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BlockfrostTxClient = exports.toOgmiosUtxo = exports.valueFromBlockfrost = exports.BlockfrostEvaluationError = void 0;
4
+ // Blockfrost access for building/evaluating/submitting script transactions, returning @cardano-sdk/core
5
+ // types. Every call goes through the shared rate-limited transport (fetchProviderJson), which honors
6
+ // Retry-After and friends process-wide.
7
+ const core_1 = require("@cardano-sdk/core");
8
+ const fetchProviderJson_1 = require("../chain/transport/fetchProviderJson");
9
+ const fees_1 = require("./fees");
10
+ const PLUTUS_LANGUAGE = {
11
+ plutusV1: core_1.Cardano.PlutusLanguageVersion.V1,
12
+ plutusV2: core_1.Cardano.PlutusLanguageVersion.V2,
13
+ plutusV3: core_1.Cardano.PlutusLanguageVersion.V3
14
+ };
15
+ const OGMIOS_PURPOSE = {
16
+ spend: 'spend',
17
+ mint: 'mint',
18
+ certificate: 'certificate',
19
+ withdrawal: 'withdrawal',
20
+ withdraw: 'withdrawal',
21
+ reward: 'withdrawal'
22
+ };
23
+ class BlockfrostEvaluationError extends Error {
24
+ constructor(failure) {
25
+ super(`Transaction failed evaluation: ${JSON.stringify(failure)}`);
26
+ this.failure = failure;
27
+ this.name = 'BlockfrostEvaluationError';
28
+ }
29
+ }
30
+ exports.BlockfrostEvaluationError = BlockfrostEvaluationError;
31
+ const valueFromBlockfrost = (amount) => {
32
+ let coins = BigInt(0);
33
+ const assets = new Map();
34
+ for (const { unit, quantity } of amount) {
35
+ if (unit === 'lovelace')
36
+ coins = BigInt(quantity);
37
+ else
38
+ assets.set(core_1.Cardano.AssetId.fromParts(core_1.Cardano.PolicyId(unit.slice(0, 56)), core_1.Cardano.AssetName(unit.slice(56))), BigInt(quantity));
39
+ }
40
+ return { coins, ...(assets.size ? { assets } : {}) };
41
+ };
42
+ exports.valueFromBlockfrost = valueFromBlockfrost;
43
+ /** Additional-UTxO shape Blockfrost's evaluator accepts: Ogmios value `{ ada: { lovelace }, [policy]: { [asset]: qty } }`. */
44
+ const toOgmiosUtxo = ([txIn, txOut]) => {
45
+ var _a, _b;
46
+ const value = { ada: { lovelace: Number(txOut.value.coins) } };
47
+ for (const [id, qty] of (_a = txOut.value.assets) !== null && _a !== void 0 ? _a : []) {
48
+ const policy = core_1.Cardano.AssetId.getPolicyId(id);
49
+ value[policy] = { ...((_b = value[policy]) !== null && _b !== void 0 ? _b : {}), [core_1.Cardano.AssetId.getAssetName(id)]: Number(qty) };
50
+ }
51
+ return [
52
+ { txId: txIn.txId, index: txIn.index },
53
+ {
54
+ address: txOut.address,
55
+ value,
56
+ ...(txOut.datum ? { datum: core_1.Serialization.PlutusData.fromCore(txOut.datum).toCbor() } : {}),
57
+ ...(txOut.datumHash ? { datumHash: txOut.datumHash } : {})
58
+ }
59
+ ];
60
+ };
61
+ exports.toOgmiosUtxo = toOgmiosUtxo;
62
+ class BlockfrostTxClient {
63
+ constructor(config) {
64
+ this.config = config;
65
+ this.scripts = new Map();
66
+ this.host = `https://cardano-${config.network.toLowerCase()}.blockfrost.io/api/v0`;
67
+ }
68
+ request(path, init = {}) {
69
+ var _a, _b;
70
+ return (0, fetchProviderJson_1.fetchProviderJson)({
71
+ provider: 'Blockfrost',
72
+ url: `${this.host}/${path.replace(/^\//, '')}`,
73
+ method: (_a = init.method) !== null && _a !== void 0 ? _a : 'GET',
74
+ headers: { project_id: this.config.blockfrostApiKey, 'Content-Type': (_b = init.contentType) !== null && _b !== void 0 ? _b : 'application/json' },
75
+ body: init.body,
76
+ fetcher: this.config.fetcher,
77
+ // Submission/evaluation are not idempotent-safe to blind-retry on 5xx; rate limits are still honored.
78
+ ...(init.method === 'POST' ? { maxRetries: 0 } : {})
79
+ });
80
+ }
81
+ async getOrNull(path) {
82
+ try {
83
+ return await this.request(path);
84
+ }
85
+ catch (error) {
86
+ if ((error === null || error === void 0 ? void 0 : error.status) === 404)
87
+ return null;
88
+ throw error;
89
+ }
90
+ }
91
+ async getProtocolParameters() {
92
+ var _a;
93
+ const p = await this.request('epochs/latest/parameters');
94
+ const raw = p.cost_models_raw;
95
+ if (!raw)
96
+ throw new Error('Protocol parameters carry no cost_models_raw');
97
+ // cost_models_raw is the LEDGER-ordered array; the named `cost_models` object is not (its
98
+ // alphabetical order produced script_data_hash mismatches once the models were extended).
99
+ const costModels = new Map();
100
+ for (const [name, model] of Object.entries(raw)) {
101
+ const language = PLUTUS_LANGUAGE[name.charAt(0).toLowerCase() + name.slice(1)];
102
+ if (language !== undefined)
103
+ costModels.set(language, model);
104
+ }
105
+ return {
106
+ minFeeA: BigInt(p.min_fee_a),
107
+ minFeeB: BigInt(p.min_fee_b),
108
+ priceMemory: (0, fees_1.ratio)(p.price_mem),
109
+ priceSteps: (0, fees_1.ratio)(p.price_step),
110
+ minFeeRefScriptCostPerByte: (0, fees_1.ratio)((_a = p.min_fee_ref_script_cost_per_byte) !== null && _a !== void 0 ? _a : 0),
111
+ coinsPerUtxoByte: BigInt(p.coins_per_utxo_size),
112
+ maxTxSize: Number(p.max_tx_size),
113
+ maxTxExUnits: { memory: Number(p.max_tx_ex_mem), steps: Number(p.max_tx_ex_steps) },
114
+ costModels
115
+ };
116
+ }
117
+ getLatestBlock() {
118
+ return this.request('blocks/latest');
119
+ }
120
+ /** Slot of a POSIX time (ms), from the tip (1-second slots since Shelley on every network). */
121
+ async slotAt(posixMs) {
122
+ const tip = await this.getLatestBlock();
123
+ return tip.slot + Math.floor((posixMs - tip.time * 1000) / 1000);
124
+ }
125
+ getScript(hash) {
126
+ let cached = this.scripts.get(hash);
127
+ if (!cached) {
128
+ cached = (async () => {
129
+ var _a, _b;
130
+ const info = await this.request(`scripts/${hash}`);
131
+ if (info.type === 'timelock')
132
+ return { hash, language: 'native', size: Number((_a = info.serialised_size) !== null && _a !== void 0 ? _a : 0) };
133
+ const language = PLUTUS_LANGUAGE[info.type];
134
+ if (language === undefined)
135
+ throw new Error(`Unknown script type ${info.type} for ${hash}`);
136
+ const { cbor } = await this.request(`scripts/${hash}/cbor`);
137
+ // Blockfrost's `cbor` is the script as the ledger hashes it (a CBOR byte string of the flat program).
138
+ const script = { __type: core_1.Cardano.ScriptType.Plutus, version: language, bytes: cbor };
139
+ if (core_1.Serialization.Script.fromCore(script).hash() !== hash)
140
+ throw new Error(`Script ${hash} CBOR does not hash to ${hash}`);
141
+ return { hash, language, size: Number((_b = info.serialised_size) !== null && _b !== void 0 ? _b : cbor.length / 2), script };
142
+ })();
143
+ cached.catch(() => this.scripts.delete(hash));
144
+ this.scripts.set(hash, cached);
145
+ }
146
+ return cached;
147
+ }
148
+ async toUtxo(item, txHash, address) {
149
+ const scriptReference = item.reference_script_hash ? (await this.getScript(item.reference_script_hash)).script : undefined;
150
+ return [
151
+ { txId: core_1.Cardano.TransactionId(txHash), index: item.output_index, address: address },
152
+ {
153
+ address: address,
154
+ value: (0, exports.valueFromBlockfrost)(item.amount),
155
+ ...(item.inline_datum ? { datum: core_1.Serialization.PlutusData.fromCbor(item.inline_datum).toCore() } : {}),
156
+ ...(!item.inline_datum && item.data_hash ? { datumHash: item.data_hash } : {}),
157
+ ...(scriptReference ? { scriptReference } : {})
158
+ }
159
+ ];
160
+ }
161
+ /** Every UTxO at `address` (paginated). */
162
+ async getAddressUtxos(address) {
163
+ var _a;
164
+ const utxos = [];
165
+ for (let page = 1;; page++) {
166
+ const items = (_a = (await this.getOrNull(`addresses/${address}/utxos?page=${page}&count=100`))) !== null && _a !== void 0 ? _a : [];
167
+ for (const item of items)
168
+ utxos.push(await this.toUtxo(item, item.tx_hash, address));
169
+ if (items.length < 100)
170
+ return utxos;
171
+ }
172
+ }
173
+ /** UTxOs at `address` holding `unit`. */
174
+ async getAddressAssetUtxos(address, unit) {
175
+ var _a;
176
+ const items = (_a = (await this.getOrNull(`addresses/${address}/utxos/${unit}`))) !== null && _a !== void 0 ? _a : [];
177
+ return Promise.all(items.map((item) => this.toUtxo(item, item.tx_hash, address)));
178
+ }
179
+ /** The UTxO `txHash#index`; throws `already spent` when it has been consumed. */
180
+ async getUtxo(ref) {
181
+ const [txHash, index] = ref.split('#');
182
+ const tx = await this.request(`txs/${txHash}/utxos`);
183
+ const output = tx.outputs.find((o) => o.output_index === Number(index));
184
+ if (!output)
185
+ throw new Error(`${ref} does not exist`);
186
+ if (output.consumed_by_tx)
187
+ throw new Error(`${ref} already spent by ${output.consumed_by_tx}`);
188
+ return this.toUtxo(output, txHash, output.address);
189
+ }
190
+ /** Node (Ogmios) evaluation of every redeemer; `additionalUtxos` supplies inputs not yet on chain. */
191
+ async evaluateTx(cbor, additionalUtxos = []) {
192
+ var _a, _b, _c, _d, _e;
193
+ const response = await this.request('utils/txs/evaluate/utxos', { method: 'POST', body: JSON.stringify({ cbor, additionalUtxoSet: additionalUtxos.map(exports.toOgmiosUtxo) }) });
194
+ const result = (_a = response.result) === null || _a === void 0 ? void 0 : _a.EvaluationResult;
195
+ if (!result)
196
+ throw new BlockfrostEvaluationError((_d = (_c = (_b = response.result) === null || _b === void 0 ? void 0 : _b.EvaluationFailure) !== null && _c !== void 0 ? _c : response.fault) !== null && _d !== void 0 ? _d : response);
197
+ const units = new Map();
198
+ for (const [pointer, { memory, steps }] of Object.entries(result)) {
199
+ const [purpose, index] = pointer.split(':');
200
+ units.set(`${(_e = OGMIOS_PURPOSE[purpose]) !== null && _e !== void 0 ? _e : purpose}:${index}`, { memory, steps });
201
+ }
202
+ return units;
203
+ }
204
+ /** Submit a signed tx; returns its hash. */
205
+ submitTx(cbor) {
206
+ return this.request('tx/submit', { method: 'POST', body: Buffer.from(cbor, 'hex'), contentType: 'application/cbor' });
207
+ }
208
+ }
209
+ exports.BlockfrostTxClient = BlockfrostTxClient;
@@ -0,0 +1,32 @@
1
+ /** A non-negative rational n/d. */
2
+ export interface Ratio {
3
+ n: bigint;
4
+ d: bigint;
5
+ }
6
+ /** Exact rational from a decimal number/string as providers report it (0.0577, "0.0000721", 7.21e-5). */
7
+ export declare const ratio: (value: number | string | bigint) => Ratio;
8
+ export interface FeeParameters {
9
+ /** min_fee_a (lovelace per byte) */
10
+ minFeeA: bigint;
11
+ /** min_fee_b (constant lovelace) */
12
+ minFeeB: bigint;
13
+ priceMemory: Ratio;
14
+ priceSteps: Ratio;
15
+ /** min_fee_ref_script_cost_per_byte (Conway) */
16
+ minFeeRefScriptCostPerByte: Ratio;
17
+ }
18
+ export interface ExUnits {
19
+ memory: number | bigint;
20
+ steps: number | bigint;
21
+ }
22
+ /** ceiling(priceMem * Σmem + priceSteps * Σsteps) — the ledger prices the TOTAL, then rounds once. */
23
+ export declare const scriptExecutionFee: (params: Pick<FeeParameters, "priceMemory" | "priceSteps">, exUnits: ExUnits[]) => bigint;
24
+ /**
25
+ * Conway's tiered reference-script fee: every 25,600 bytes the per-byte price is multiplied by 1.2;
26
+ * the sum is floored. `totalBytes` counts every reference script of every spent AND referenced input.
27
+ */
28
+ export declare const referenceScriptFee: (costPerByte: Ratio, totalBytes: number | bigint) => bigint;
29
+ /** The ledger minimum fee for a tx of `sizeBytes` (as submitted, witnesses included). */
30
+ export declare const minFee: (params: FeeParameters, sizeBytes: number, exUnits: ExUnits[], referenceScriptBytes: number) => bigint;
31
+ /** Babbage/Conway min-UTxO: (160 + |serialized output|) * coinsPerUtxoByte. */
32
+ export declare const minAdaForOutputSize: (coinsPerUtxoByte: bigint, outputSizeBytes: number) => bigint;
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ // Exact Conway fee / deposit arithmetic (BigInt rationals — no floating point anywhere a ledger rule
3
+ // uses a ratio). Pure: no network, no cardano-sdk.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.minAdaForOutputSize = exports.minFee = exports.referenceScriptFee = exports.scriptExecutionFee = exports.ratio = void 0;
6
+ /** Exact rational from a decimal number/string as providers report it (0.0577, "0.0000721", 7.21e-5). */
7
+ const ratio = (value) => {
8
+ if (typeof value === 'bigint')
9
+ return { n: value, d: BigInt(1) };
10
+ const text = typeof value === 'number' ? value.toString() : value.trim();
11
+ const match = /^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(text);
12
+ if (!match)
13
+ throw new Error(`Not a non-negative decimal: ${value}`);
14
+ const [, whole, fraction = '', exponent = '0'] = match;
15
+ let n = BigInt(whole + fraction);
16
+ let d = BigInt(10) ** BigInt(fraction.length);
17
+ const exp = Number(exponent);
18
+ if (exp > 0)
19
+ n *= BigInt(10) ** BigInt(exp);
20
+ if (exp < 0)
21
+ d *= BigInt(10) ** BigInt(-exp);
22
+ return { n, d };
23
+ };
24
+ exports.ratio = ratio;
25
+ const ceilDiv = (n, d) => (n + d - BigInt(1)) / d;
26
+ /** ceiling(priceMem * Σmem + priceSteps * Σsteps) — the ledger prices the TOTAL, then rounds once. */
27
+ const scriptExecutionFee = (params, exUnits) => {
28
+ const memory = exUnits.reduce((sum, e) => sum + BigInt(e.memory), BigInt(0));
29
+ const steps = exUnits.reduce((sum, e) => sum + BigInt(e.steps), BigInt(0));
30
+ const { priceMemory: pm, priceSteps: ps } = params;
31
+ return ceilDiv(pm.n * memory * ps.d + ps.n * steps * pm.d, pm.d * ps.d);
32
+ };
33
+ exports.scriptExecutionFee = scriptExecutionFee;
34
+ const REF_SCRIPT_SIZE_INCREMENT = BigInt(25600);
35
+ const REF_SCRIPT_MULTIPLIER = { n: BigInt(6), d: BigInt(5) };
36
+ /**
37
+ * Conway's tiered reference-script fee: every 25,600 bytes the per-byte price is multiplied by 1.2;
38
+ * the sum is floored. `totalBytes` counts every reference script of every spent AND referenced input.
39
+ */
40
+ const referenceScriptFee = (costPerByte, totalBytes) => {
41
+ let remaining = BigInt(totalBytes);
42
+ let price = costPerByte;
43
+ let acc = { n: BigInt(0), d: BigInt(1) };
44
+ const add = (a, b) => ({ n: a.n * b.d + b.n * a.d, d: a.d * b.d });
45
+ while (remaining >= REF_SCRIPT_SIZE_INCREMENT) {
46
+ acc = add(acc, { n: price.n * REF_SCRIPT_SIZE_INCREMENT, d: price.d });
47
+ price = { n: price.n * REF_SCRIPT_MULTIPLIER.n, d: price.d * REF_SCRIPT_MULTIPLIER.d };
48
+ remaining -= REF_SCRIPT_SIZE_INCREMENT;
49
+ }
50
+ acc = add(acc, { n: price.n * remaining, d: price.d });
51
+ return acc.n / acc.d;
52
+ };
53
+ exports.referenceScriptFee = referenceScriptFee;
54
+ /** The ledger minimum fee for a tx of `sizeBytes` (as submitted, witnesses included). */
55
+ const minFee = (params, sizeBytes, exUnits, referenceScriptBytes) => params.minFeeA * BigInt(sizeBytes) +
56
+ params.minFeeB +
57
+ (0, exports.scriptExecutionFee)(params, exUnits) +
58
+ (0, exports.referenceScriptFee)(params.minFeeRefScriptCostPerByte, referenceScriptBytes);
59
+ exports.minFee = minFee;
60
+ /** Babbage/Conway min-UTxO: (160 + |serialized output|) * coinsPerUtxoByte. */
61
+ const minAdaForOutputSize = (coinsPerUtxoByte, outputSizeBytes) => (BigInt(160) + BigInt(outputSizeBytes)) * coinsPerUtxoByte;
62
+ exports.minAdaForOutputSize = minAdaForOutputSize;
@@ -0,0 +1,3 @@
1
+ export * from './fees';
2
+ export * from './scriptTx';
3
+ export * from './blockfrost';
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ // Subpath module: `@koralabs/kora-labs-common/txBuild`. Server-side Plutus transaction building on
18
+ // @cardano-sdk/core (optional peer dependency). NOT re-exported from the root index.
19
+ __exportStar(require("./fees"), exports);
20
+ __exportStar(require("./scriptTx"), exports);
21
+ __exportStar(require("./blockfrost"), exports);
@@ -0,0 +1,79 @@
1
+ import { Cardano, Serialization } from '@cardano-sdk/core';
2
+ import { ExUnits, FeeParameters } from './fees';
3
+ export type PlutusData = Serialization.PlutusData;
4
+ export interface ScriptTxProtocolParameters extends FeeParameters {
5
+ coinsPerUtxoByte: bigint;
6
+ maxTxSize: number;
7
+ maxTxExUnits: {
8
+ memory: number;
9
+ steps: number;
10
+ };
11
+ /** Ledger-ordered cost models (Blockfrost `cost_models_raw`), keyed by Plutus language. */
12
+ costModels: Map<Cardano.PlutusLanguageVersion, number[]>;
13
+ }
14
+ export interface ScriptTxPlan {
15
+ /** Every spent input, resolved. A redeemer marks a Plutus script spend. */
16
+ inputs: {
17
+ utxo: Cardano.Utxo;
18
+ redeemer?: PlutusData;
19
+ }[];
20
+ referenceInputs?: Cardano.TxIn[];
21
+ collateral?: Cardano.TxIn[];
22
+ /** Kept in this order. Outputs below min-UTxO are topped up to it. */
23
+ outputs: Cardano.TxOut[];
24
+ /** One entry per policy. A redeemer marks a Plutus policy (native policies need `nativeScripts`). */
25
+ mint?: {
26
+ policyId: string;
27
+ assets: Map<string, bigint>;
28
+ redeemer?: PlutusData;
29
+ }[];
30
+ withdrawals?: {
31
+ rewardAccount: string;
32
+ quantity: bigint;
33
+ redeemer?: PlutusData;
34
+ }[];
35
+ requiredSigners?: string[];
36
+ nativeScripts?: Cardano.NativeScript[];
37
+ validityInterval?: Cardano.ValidityInterval;
38
+ changeAddress: string;
39
+ /** vkey witnesses the signed tx will carry (sizes the fee). */
40
+ signerCount: number;
41
+ /** Languages of every Plutus script the tx runs (from their reference scripts). */
42
+ plutusLanguages: Cardano.PlutusLanguageVersion[];
43
+ /** Σ size of every reference script on spent + referenced inputs (Conway ref-script fee). */
44
+ referenceScriptBytes: number;
45
+ }
46
+ /** Redeemer pointer key: `${purpose}:${index}` (e.g. `spend:1`, `mint:0`, `withdrawal:0`). */
47
+ export type RedeemerKey = string;
48
+ export type Evaluator = (unsignedTxCbor: string) => Promise<Map<RedeemerKey, ExUnits>>;
49
+ export interface FinalizedScriptTx {
50
+ /** Unsigned tx CBOR (empty vkey witness set). */
51
+ cbor: string;
52
+ txId: string;
53
+ fee: bigint;
54
+ /** Final outputs, change last. */
55
+ outputs: Cardano.TxOut[];
56
+ redeemers: Cardano.Redeemer[];
57
+ }
58
+ /** Raise `output` to its min-UTxO (the coin's own CBOR width is part of the size, so iterate). */
59
+ export declare const withMinAda: (output: Cardano.TxOut, coinsPerUtxoByte: bigint) => Cardano.TxOut;
60
+ /**
61
+ * script_data_hash = blake2b-256(redeemers ‖ datums? ‖ language_views), over the exact bytes of the
62
+ * serialized witness-set fields. `datumsCbor` is only for witness-set datums (never inline datums).
63
+ */
64
+ export declare const computeScriptDataHash: ({ redeemersCbor, datumsCbor, costModels, languages }: {
65
+ redeemersCbor?: string;
66
+ datumsCbor?: string;
67
+ costModels: Map<Cardano.PlutusLanguageVersion, number[]>;
68
+ languages: Cardano.PlutusLanguageVersion[];
69
+ }) => string | undefined;
70
+ export declare const finalizeScriptTx: (plan: ScriptTxPlan, params: ScriptTxProtocolParameters, evaluate: Evaluator) => Promise<FinalizedScriptTx>;
71
+ /**
72
+ * Add vkey witnesses to an unsigned tx by splicing them into its witness set byte-for-byte
73
+ * (`mergeWitnessSet`): the body (so the tx id and every signature) and the redeemer bytes the
74
+ * script_data_hash covers are untouched.
75
+ */
76
+ export declare const addVkeyWitnesses: (unsignedCbor: string, witnesses: {
77
+ vkey: string;
78
+ signature: string;
79
+ }[]) => string;
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.addVkeyWitnesses = exports.finalizeScriptTx = exports.computeScriptDataHash = exports.withMinAda = void 0;
4
+ // Deterministic Plutus transaction finalizer on @cardano-sdk/core (no input selection, no Helios).
5
+ //
6
+ // The caller fixes every input; this module derives redeemer pointers from the ledger's canonical
7
+ // orderings, tops outputs up to min-UTxO, asks the NODE (via `evaluate`) for each redeemer's
8
+ // execution units, converges the fee to the exact ledger minimum, puts the remainder in one change
9
+ // output, and computes script_data_hash over the exact redeemer bytes it serializes.
10
+ //
11
+ // Lessons baked in (handle.me docs/spec/live-tx-lessons-learned.md):
12
+ // - Conway redeemers are map-encoded; script_data_hash must hash the bytes actually shipped (#1).
13
+ // - inline datums never enter script_data_hash (#2).
14
+ // - reference inputs / inputs / policies / withdrawals are indexed in ledger-sorted order (#3).
15
+ // - execution units come from the node's evaluator, re-checked after the fee settles (the fee and
16
+ // change are part of the ScriptContext, so a fee change can change the cost).
17
+ const core_1 = require("@cardano-sdk/core");
18
+ const blakejs_1 = require("blakejs");
19
+ const tx_1 = require("../tx");
20
+ const fees_1 = require("./fees");
21
+ (0, core_1.setInConwayEra)(true);
22
+ const byTxIn = (a, b) => (a.txId === b.txId ? a.index - b.index : a.txId < b.txId ? -1 : 1);
23
+ const hexCompare = (a, b) => (a === b ? 0 : a < b ? -1 : 1);
24
+ const rewardAccountBytes = (account) => core_1.Cardano.Address.fromBech32(account).toBytes();
25
+ const outputSize = (output) => core_1.Serialization.TransactionOutput.fromCore(output).toCbor().length / 2;
26
+ /** Raise `output` to its min-UTxO (the coin's own CBOR width is part of the size, so iterate). */
27
+ const withMinAda = (output, coinsPerUtxoByte) => {
28
+ let current = output;
29
+ for (let i = 0; i < 5; i++) {
30
+ const required = (0, fees_1.minAdaForOutputSize)(coinsPerUtxoByte, outputSize(current));
31
+ if (current.value.coins >= required)
32
+ return current;
33
+ current = { ...current, value: { ...current.value, coins: required } };
34
+ }
35
+ throw new Error('min-UTxO did not converge');
36
+ };
37
+ exports.withMinAda = withMinAda;
38
+ /**
39
+ * script_data_hash = blake2b-256(redeemers ‖ datums? ‖ language_views), over the exact bytes of the
40
+ * serialized witness-set fields. `datumsCbor` is only for witness-set datums (never inline datums).
41
+ */
42
+ const computeScriptDataHash = ({ redeemersCbor, datumsCbor, costModels, languages }) => {
43
+ if (!redeemersCbor && !datumsCbor)
44
+ return undefined;
45
+ const views = new core_1.Serialization.Costmdls();
46
+ for (const language of new Set(languages)) {
47
+ const model = costModels.get(language);
48
+ if (!model)
49
+ throw new Error(`No cost model for Plutus language ${language}`);
50
+ views.insert(new core_1.Serialization.CostModel(language, model));
51
+ }
52
+ const EMPTY_MAP = 'a0';
53
+ const preimage = redeemersCbor ? redeemersCbor + (datumsCbor !== null && datumsCbor !== void 0 ? datumsCbor : '') + views.languageViewsEncoding() : EMPTY_MAP + datumsCbor + EMPTY_MAP;
54
+ return (0, blakejs_1.blake2bHex)(Buffer.from(preimage, 'hex'), undefined, 32);
55
+ };
56
+ exports.computeScriptDataHash = computeScriptDataHash;
57
+ const placeholderSignatures = (count) => {
58
+ const signatures = new Map();
59
+ for (let i = 0; i < count; i++)
60
+ signatures.set((i + 1).toString(16).padStart(64, '0'), '0'.repeat(128));
61
+ return signatures;
62
+ };
63
+ const sumValues = (values) => {
64
+ var _a, _b;
65
+ let coins = BigInt(0);
66
+ const assets = new Map();
67
+ for (const value of values) {
68
+ coins += value.coins;
69
+ for (const [id, qty] of (_a = value.assets) !== null && _a !== void 0 ? _a : [])
70
+ assets.set(id, ((_b = assets.get(id)) !== null && _b !== void 0 ? _b : BigInt(0)) + qty);
71
+ }
72
+ return { coins, assets };
73
+ };
74
+ const finalizeScriptTx = async (plan, params, evaluate) => {
75
+ var _a, _b;
76
+ const inputs = [...plan.inputs].sort((a, b) => byTxIn(a.utxo[0], b.utxo[0]));
77
+ const mint = [...((_a = plan.mint) !== null && _a !== void 0 ? _a : [])].sort((a, b) => hexCompare(a.policyId, b.policyId));
78
+ const withdrawals = [...((_b = plan.withdrawals) !== null && _b !== void 0 ? _b : [])].sort((a, b) => hexCompare(rewardAccountBytes(a.rewardAccount), rewardAccountBytes(b.rewardAccount)));
79
+ const outputs = plan.outputs.map((output) => (0, exports.withMinAda)(output, params.coinsPerUtxoByte));
80
+ const pointers = [
81
+ ...inputs.flatMap(({ redeemer }, index) => (redeemer ? [{ purpose: core_1.Cardano.RedeemerPurpose.spend, index, data: redeemer }] : [])),
82
+ ...mint.flatMap(({ redeemer }, index) => (redeemer ? [{ purpose: core_1.Cardano.RedeemerPurpose.mint, index, data: redeemer }] : [])),
83
+ ...withdrawals.flatMap(({ redeemer }, index) => (redeemer ? [{ purpose: core_1.Cardano.RedeemerPurpose.withdrawal, index, data: redeemer }] : []))
84
+ ];
85
+ const key = (purpose, index) => `${purpose}:${index}`;
86
+ const tokenMap = new Map();
87
+ for (const { policyId, assets } of mint) {
88
+ for (const [name, qty] of assets)
89
+ tokenMap.set(core_1.Cardano.AssetId.fromParts(core_1.Cardano.PolicyId(policyId), core_1.Cardano.AssetName(name)), qty);
90
+ }
91
+ const inValue = sumValues(inputs.map(({ utxo }) => utxo[1].value));
92
+ const withdrawn = withdrawals.reduce((sum, w) => sum + w.quantity, BigInt(0));
93
+ const produced = sumValues(outputs.map((o) => o.value));
94
+ const changeFor = (fee) => {
95
+ var _a, _b, _c;
96
+ const coins = inValue.coins + withdrawn - produced.coins - fee;
97
+ if (coins < BigInt(0))
98
+ throw new Error(`Inputs do not cover outputs + fee: ${-coins} lovelace short`);
99
+ const assets = new Map();
100
+ const ids = new Set([...inValue.assets.keys(), ...tokenMap.keys(), ...produced.assets.keys()]);
101
+ for (const id of ids) {
102
+ const qty = ((_a = inValue.assets.get(id)) !== null && _a !== void 0 ? _a : BigInt(0)) + ((_b = tokenMap.get(id)) !== null && _b !== void 0 ? _b : BigInt(0)) - ((_c = produced.assets.get(id)) !== null && _c !== void 0 ? _c : BigInt(0));
103
+ if (qty < BigInt(0))
104
+ throw new Error(`Outputs carry more ${id} than the inputs and mint provide`);
105
+ if (qty > BigInt(0))
106
+ assets.set(id, qty);
107
+ }
108
+ return { address: plan.changeAddress, value: { coins, ...(assets.size ? { assets } : {}) } };
109
+ };
110
+ const assemble = (fee, exUnits, signatures) => {
111
+ var _a, _b, _c, _d;
112
+ const change = changeFor(fee);
113
+ const redeemers = pointers.map(({ purpose, index, data }) => {
114
+ const units = exUnits.get(key(purpose, index));
115
+ if (!units)
116
+ throw new Error(`No execution units for redeemer ${key(purpose, index)}`);
117
+ return { purpose, index, data: data.toCore(), executionUnits: { memory: Number(units.memory), steps: Number(units.steps) } };
118
+ });
119
+ const redeemersCbor = redeemers.length ? core_1.Serialization.Redeemers.fromCore(redeemers).toCbor() : undefined;
120
+ const scriptIntegrityHash = (0, exports.computeScriptDataHash)({ redeemersCbor, costModels: params.costModels, languages: plan.plutusLanguages });
121
+ const body = {
122
+ inputs: inputs.map(({ utxo: [txIn] }) => ({ txId: txIn.txId, index: txIn.index })),
123
+ outputs: [...outputs, change],
124
+ fee,
125
+ ...(((_a = plan.referenceInputs) === null || _a === void 0 ? void 0 : _a.length) ? { referenceInputs: [...plan.referenceInputs].sort(byTxIn) } : {}),
126
+ ...(((_b = plan.collateral) === null || _b === void 0 ? void 0 : _b.length) ? { collaterals: [...plan.collateral].sort(byTxIn) } : {}),
127
+ ...(tokenMap.size ? { mint: tokenMap } : {}),
128
+ ...(withdrawals.length ? { withdrawals: withdrawals.map((w) => ({ stakeAddress: w.rewardAccount, quantity: w.quantity })) } : {}),
129
+ ...(((_c = plan.requiredSigners) === null || _c === void 0 ? void 0 : _c.length) ? { requiredExtraSignatures: [...new Set(plan.requiredSigners)].sort(hexCompare) } : {}),
130
+ ...(plan.validityInterval ? { validityInterval: plan.validityInterval } : {}),
131
+ ...(scriptIntegrityHash ? { scriptIntegrityHash: scriptIntegrityHash } : {})
132
+ };
133
+ const tx = core_1.Serialization.Transaction.fromCore({
134
+ id: '0'.repeat(64),
135
+ body,
136
+ witness: {
137
+ signatures: signatures,
138
+ ...(redeemers.length ? { redeemers } : {}),
139
+ ...(((_d = plan.nativeScripts) === null || _d === void 0 ? void 0 : _d.length) ? { scripts: plan.nativeScripts } : {})
140
+ },
141
+ isValid: true
142
+ });
143
+ return { tx, change, redeemers };
144
+ };
145
+ // Evaluation budget placeholder: the node evaluates against the protocol maximum, not the declared units.
146
+ const share = Math.max(1, pointers.length);
147
+ const placeholder = new Map(pointers.map(({ purpose, index }) => [key(purpose, index), {
148
+ memory: Math.floor(params.maxTxExUnits.memory / share),
149
+ steps: Math.floor(params.maxTxExUnits.steps / share)
150
+ }]));
151
+ const sizeOf = (fee, exUnits) => assemble(fee, exUnits, placeholderSignatures(plan.signerCount)).tx.toCbor().length / 2;
152
+ let exUnits = pointers.length ? await evaluate(assemble(BigInt(0), placeholder, new Map()).tx.toCbor()) : new Map();
153
+ let fee = BigInt(0);
154
+ for (let round = 0; round < 6; round++) {
155
+ // Converge the fee for these units: the fee's own CBOR width changes the size it pays for.
156
+ for (let i = 0; i < 6; i++) {
157
+ const needed = (0, fees_1.minFee)(params, sizeOf(fee, exUnits), [...exUnits.values()], plan.referenceScriptBytes);
158
+ if (needed <= fee)
159
+ break;
160
+ fee = needed;
161
+ }
162
+ if (!pointers.length)
163
+ break;
164
+ // The fee and change are part of the ScriptContext: re-evaluate the tx we would actually submit.
165
+ const reevaluated = await evaluate(assemble(fee, exUnits, new Map()).tx.toCbor());
166
+ const unchanged = [...reevaluated].every(([k, u]) => {
167
+ const prior = exUnits.get(k);
168
+ return prior && BigInt(prior.memory) === BigInt(u.memory) && BigInt(prior.steps) === BigInt(u.steps);
169
+ });
170
+ exUnits = reevaluated;
171
+ if (unchanged && (0, fees_1.minFee)(params, sizeOf(fee, exUnits), [...exUnits.values()], plan.referenceScriptBytes) <= fee)
172
+ break;
173
+ if (round === 5)
174
+ throw new Error('Execution units did not converge');
175
+ }
176
+ const { tx, change, redeemers } = assemble(fee, exUnits, new Map());
177
+ if (change.value.coins < (0, fees_1.minAdaForOutputSize)(params.coinsPerUtxoByte, outputSize(change))) {
178
+ throw new Error(`Inputs do not cover outputs + fee: change of ${change.value.coins} lovelace is below min-UTxO`);
179
+ }
180
+ const signedSize = sizeOf(fee, exUnits);
181
+ if (signedSize > params.maxTxSize)
182
+ throw new Error(`Transaction is ${signedSize} bytes, above the ${params.maxTxSize} byte limit`);
183
+ return { cbor: tx.toCbor(), txId: tx.getId(), fee, outputs: [...outputs, change], redeemers };
184
+ };
185
+ exports.finalizeScriptTx = finalizeScriptTx;
186
+ /**
187
+ * Add vkey witnesses to an unsigned tx by splicing them into its witness set byte-for-byte
188
+ * (`mergeWitnessSet`): the body (so the tx id and every signature) and the redeemer bytes the
189
+ * script_data_hash covers are untouched.
190
+ */
191
+ const addVkeyWitnesses = (unsignedCbor, witnesses) => {
192
+ const witnessSet = core_1.Serialization.TransactionWitnessSet.fromCore({
193
+ signatures: new Map(witnesses.map(({ vkey, signature }) => [vkey, signature]))
194
+ });
195
+ return (0, tx_1.mergeWitnessSet)(unsignedCbor, witnessSet.toCbor());
196
+ };
197
+ exports.addVkeyWitnesses = addVkeyWitnesses;
@@ -149,17 +149,13 @@ class JsonToDatumObject {
149
149
  indefiniteArrays: (_b = options === null || options === void 0 ? void 0 : options.indefiniteArrays) !== null && _b !== void 0 ? _b : true,
150
150
  defaultToText: options === null || options === void 0 ? void 0 : options.defaultToText
151
151
  };
152
- if (Array.isArray(this.json)) {
153
- for (let i = 0; i < this.json.length; i++) {
154
- this.json[i] = new JsonToDatumObject(this.json[i], this.options);
155
- }
152
+ // Wrap children into NEW containers: the caller's JSON must not be modified (re-encoding the
153
+ // same object used to produce garbage, since its children had been replaced by wrappers).
154
+ if (Array.isArray(json)) {
155
+ this.json = json.map((item) => new JsonToDatumObject(item, this.options));
156
156
  }
157
- else if (typeof this.json === 'object') {
158
- if (this.json !== null) {
159
- Object.keys(this.json).map((key) => {
160
- this.json[key] = new JsonToDatumObject(this.json[key], this.options);
161
- });
162
- }
157
+ else if (typeof json === 'object' && json !== null && !(json instanceof Map)) {
158
+ this.json = Object.fromEntries(Object.keys(json).map((key) => [key, new JsonToDatumObject(json[key], this.options)]));
163
159
  }
164
160
  }
165
161
  }