@dcasegr/amm-client 0.0.0-stage → 0.38.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.
Files changed (121) hide show
  1. package/README.md +107 -2
  2. package/dist/client.d.ts +534 -0
  3. package/dist/client.js +1319 -0
  4. package/dist/errors.d.ts +17 -0
  5. package/dist/errors.js +75 -0
  6. package/dist/index.d.ts +51 -0
  7. package/dist/index.js +53 -0
  8. package/dist/lib/config.d.ts +42 -0
  9. package/dist/lib/config.js +64 -0
  10. package/dist/lib/keys.d.ts +42 -0
  11. package/dist/lib/keys.js +48 -0
  12. package/dist/lib/overlay.d.ts +190 -0
  13. package/dist/lib/overlay.js +302 -0
  14. package/dist/lib/skein.d.ts +82 -0
  15. package/dist/lib/skein.js +142 -0
  16. package/dist/lib/tokenId.d.ts +72 -0
  17. package/dist/lib/tokenId.js +121 -0
  18. package/dist/lp/amounts.d.ts +12 -0
  19. package/dist/lp/amounts.js +38 -0
  20. package/dist/lp/close.d.ts +90 -0
  21. package/dist/lp/close.js +140 -0
  22. package/dist/lp/images.d.ts +11 -0
  23. package/dist/lp/images.js +45 -0
  24. package/dist/lp/inventory.d.ts +68 -0
  25. package/dist/lp/inventory.js +84 -0
  26. package/dist/lp/poolDeploy.d.ts +220 -0
  27. package/dist/lp/poolDeploy.js +291 -0
  28. package/dist/lp/poolRows.d.ts +13 -0
  29. package/dist/lp/poolRows.js +19 -0
  30. package/dist/lp/positions.d.ts +81 -0
  31. package/dist/lp/positions.js +144 -0
  32. package/dist/lp/rebalance.d.ts +101 -0
  33. package/dist/lp/rebalance.js +168 -0
  34. package/dist/lp/wallet.d.ts +12 -0
  35. package/dist/lp/wallet.js +19 -0
  36. package/dist/market/activeSwap.d.ts +158 -0
  37. package/dist/market/activeSwap.js +320 -0
  38. package/dist/market/plan.d.ts +85 -0
  39. package/dist/market/plan.js +149 -0
  40. package/dist/market/prices.d.ts +13 -0
  41. package/dist/market/prices.js +14 -0
  42. package/dist/market/relay.d.ts +84 -0
  43. package/dist/market/relay.js +201 -0
  44. package/dist/market/swapAction.d.ts +306 -0
  45. package/dist/market/swapAction.js +508 -0
  46. package/dist/market/swapFlow.d.ts +58 -0
  47. package/dist/market/swapFlow.js +86 -0
  48. package/dist/market/view.d.ts +58 -0
  49. package/dist/market/view.js +63 -0
  50. package/dist/pool/artifact.d.ts +23 -0
  51. package/dist/pool/artifact.js +38 -0
  52. package/dist/pool/index.d.ts +8 -0
  53. package/dist/pool/index.js +8 -0
  54. package/dist/pool/oneColor.d.ts +19 -0
  55. package/dist/pool/oneColor.js +39 -0
  56. package/dist/pool/pool.artifact.json +5069 -0
  57. package/dist/pool/runar-ir-schema.d.ts +21 -0
  58. package/dist/pool/runar-ir-schema.js +18 -0
  59. package/dist/pool/template.d.ts +295 -0
  60. package/dist/pool/template.js +715 -0
  61. package/dist/vendor/runar/LICENSE +21 -0
  62. package/dist/vendor/runar/NOTICE +15 -0
  63. package/dist/vendor/runar/runar-ir-schema/src/abi-type-encoding.d.ts +42 -0
  64. package/dist/vendor/runar/runar-ir-schema/src/abi-type-encoding.js +53 -0
  65. package/dist/vendor/runar/runar-ir-schema/src/anf-ir.d.ts +221 -0
  66. package/dist/vendor/runar/runar-ir-schema/src/anf-ir.js +33 -0
  67. package/dist/vendor/runar/runar-ir-schema/src/artifact.d.ts +288 -0
  68. package/dist/vendor/runar/runar-ir-schema/src/artifact.js +8 -0
  69. package/dist/vendor/runar/runar-ir-schema/src/canonical-json.d.ts +43 -0
  70. package/dist/vendor/runar/runar-ir-schema/src/canonical-json.js +204 -0
  71. package/dist/vendor/runar/runar-ir-schema/src/input-limits.d.ts +89 -0
  72. package/dist/vendor/runar/runar-ir-schema/src/input-limits.js +100 -0
  73. package/dist/vendor/runar/runar-ir-schema/src/stack-ir.d.ts +118 -0
  74. package/dist/vendor/runar/runar-ir-schema/src/stack-ir.js +8 -0
  75. package/dist/vendor/runar/runar-ir-schema/src/state-layout.d.ts +56 -0
  76. package/dist/vendor/runar/runar-ir-schema/src/state-layout.js +145 -0
  77. package/dist/vendor/runar/runar-ir-schema/src/unknown-anf-kind-error.d.ts +18 -0
  78. package/dist/vendor/runar/runar-ir-schema/src/unknown-anf-kind-error.js +25 -0
  79. package/dist/vendor/runar/runar-sdk/src/anf-interpreter.d.ts +160 -0
  80. package/dist/vendor/runar/runar-sdk/src/anf-interpreter.js +1149 -0
  81. package/dist/vendor/runar/runar-sdk/src/calling.d.ts +98 -0
  82. package/dist/vendor/runar/runar-sdk/src/calling.js +209 -0
  83. package/dist/vendor/runar/runar-sdk/src/contract.d.ts +443 -0
  84. package/dist/vendor/runar/runar-sdk/src/contract.js +2516 -0
  85. package/dist/vendor/runar/runar-sdk/src/deployment.d.ts +36 -0
  86. package/dist/vendor/runar/runar-sdk/src/deployment.js +119 -0
  87. package/dist/vendor/runar/runar-sdk/src/errors.d.ts +46 -0
  88. package/dist/vendor/runar/runar-sdk/src/errors.js +59 -0
  89. package/dist/vendor/runar/runar-sdk/src/oppushtx.d.ts +23 -0
  90. package/dist/vendor/runar/runar-sdk/src/oppushtx.js +95 -0
  91. package/dist/vendor/runar/runar-sdk/src/ordinals/envelope.d.ts +31 -0
  92. package/dist/vendor/runar/runar-sdk/src/ordinals/envelope.js +272 -0
  93. package/dist/vendor/runar/runar-sdk/src/ordinals/types.d.ts +21 -0
  94. package/dist/vendor/runar/runar-sdk/src/ordinals/types.js +4 -0
  95. package/dist/vendor/runar/runar-sdk/src/providers/provider.d.ts +65 -0
  96. package/dist/vendor/runar/runar-sdk/src/providers/provider.js +59 -0
  97. package/dist/vendor/runar/runar-sdk/src/providers/wallet-provider.d.ts +94 -0
  98. package/dist/vendor/runar/runar-sdk/src/providers/wallet-provider.js +297 -0
  99. package/dist/vendor/runar/runar-sdk/src/script-utils.d.ts +54 -0
  100. package/dist/vendor/runar/runar-sdk/src/script-utils.js +252 -0
  101. package/dist/vendor/runar/runar-sdk/src/signers/signer.d.ts +17 -0
  102. package/dist/vendor/runar/runar-sdk/src/signers/signer.js +4 -0
  103. package/dist/vendor/runar/runar-sdk/src/spend-safety.d.ts +31 -0
  104. package/dist/vendor/runar/runar-sdk/src/spend-safety.js +35 -0
  105. package/dist/vendor/runar/runar-sdk/src/state.d.ts +54 -0
  106. package/dist/vendor/runar/runar-sdk/src/state.js +584 -0
  107. package/dist/vendor/runar/runar-sdk/src/types.d.ts +227 -0
  108. package/dist/vendor/runar/runar-sdk/src/types.js +4 -0
  109. package/dist/vendor/runar/runar-sdk/src/unsound-primitives.d.ts +30 -0
  110. package/dist/vendor/runar/runar-sdk/src/unsound-primitives.js +42 -0
  111. package/dist/vendor/runar/runar-sdk.d.ts +9 -0
  112. package/dist/vendor/runar/runar-sdk.js +8 -0
  113. package/dist/wallet/action.d.ts +28 -0
  114. package/dist/wallet/action.js +42 -0
  115. package/dist/wallet/brc29.d.ts +83 -0
  116. package/dist/wallet/brc29.js +80 -0
  117. package/dist/wallet/color.d.ts +110 -0
  118. package/dist/wallet/color.js +134 -0
  119. package/dist/wallet/swapPayouts.d.ts +35 -0
  120. package/dist/wallet/swapPayouts.js +146 -0
  121. package/package.json +39 -4
@@ -0,0 +1,2516 @@
1
+ // ---------------------------------------------------------------------------
2
+ // runar-sdk/contract.ts — Main contract runtime wrapper
3
+ // ---------------------------------------------------------------------------
4
+ import { InputLimits } from '../../../../pool/runar-ir-schema.js';
5
+ import { assertScriptHexUnderLimit, WitnessValueMissingError } from './errors.js';
6
+ import { txToTransactionData, warnNonFatal } from './providers/provider.js';
7
+ import { buildDeployTransaction, selectUtxos } from './deployment.js';
8
+ import { buildCallTransaction, resolveInputSequence } from './calling.js';
9
+ import { serializeState, extractStateFromScript, findLastOpReturn } from './state.js';
10
+ import { computeOpPushTx } from './oppushtx.js';
11
+ import { buildP2PKHScript, extractConstructorArgs } from './script-utils.js';
12
+ import { computeNewStateAndDataOutputs } from './anf-interpreter.js';
13
+ import { buildInscriptionEnvelope, parseInscriptionEnvelope } from './ordinals/envelope.js';
14
+ import { Utils, Hash, Transaction as BsvTransaction, LockingScript, UnlockingScript, Spend } from '@bsv/sdk';
15
+ import { WalletProvider } from './providers/wallet-provider.js';
16
+ import { detachUnlockingScript } from './spend-safety.js';
17
+ import { assertUnsoundPrimitivesAcknowledged } from './unsound-primitives.js';
18
+ /**
19
+ * Producer-side marker (issue #106) for the deliberately-empty branch of an
20
+ * OR-CHECKSIG method — `checkSig(sigA, pkA) || checkSig(sigB, pkB)`, where
21
+ * `||` short-circuits (NEW-014), so the second `OP_CHECKSIG` runs only when the
22
+ * first branch FAILS — and when it does, the first ran with a non-empty
23
+ * signature. Only the
24
+ * matching branch supplies a real signature; the failing branch MUST push an
25
+ * empty signature (OP_0) or BIP146 NULLFAIL rejects the whole spend.
26
+ *
27
+ * Pass `EMPTY_SIG` as the call arg for the non-matching `Sig` slot: the SDK
28
+ * pushes OP_0 for it and never signs it, distinct from `null` (auto-sign) and
29
+ * an explicit hex-bytes value. Coexists with `null` at the same call —
30
+ * `call('execute', [null, EMPTY_SIG])` signs only slot 0.
31
+ *
32
+ * Uses the global symbol registry so identity holds across duplicate module
33
+ * instances (bundlers).
34
+ */
35
+ export const EMPTY_SIG = Symbol.for('runar.sdk.emptySig');
36
+ /**
37
+ * Deep-review finding C29: `PreparedCall` hands the caller a live, mutable
38
+ * `Transaction` (`prepared.tx`) with no consume guard. `finalizeCall`
39
+ * mutates `prepared.tx.inputs[0].unlockingScript` in place and broadcasts —
40
+ * nothing stops the SAME `PreparedCall` from being finalized a second time
41
+ * against a tx that has already been consumed (and, on a real network,
42
+ * already spent), producing signatures that no longer correspond to a
43
+ * fresh, still-live UTXO.
44
+ *
45
+ * Tracked by object identity (not a field on `PreparedCall` itself) so the
46
+ * public shape stays untouched — a `WeakSet` also lets consumed entries be
47
+ * garbage-collected once the caller drops its reference. Full
48
+ * freezing/defensive-copying of `prepared.tx` was considered but rejected:
49
+ * `finalizeCall` itself must mutate `prepared.tx` in place to insert the
50
+ * final unlocking script, so freezing the object would break the one
51
+ * caller that legitimately needs to write to it.
52
+ */
53
+ const consumedPreparedCalls = new WeakSet();
54
+ /** Type guard: is this call arg the {@link EMPTY_SIG} marker (issue #106)? */
55
+ export function isEmptySig(value) {
56
+ return value === EMPTY_SIG;
57
+ }
58
+ /**
59
+ * True when the locking script looks like OR-CHECKSIG (OP_BOOLOR + OP_CHECKSIG)
60
+ * rather than multi-sig (OP_CHECKMULTISIG). Used to scope the multi-null-Sig
61
+ * soft warning so genuine checkMultiSig unlocks are not spammed.
62
+ *
63
+ * Exported for unit tests.
64
+ */
65
+ export function isLikelyOrCheckSigMethod(artifact) {
66
+ const asm = (artifact.asm ?? '').toUpperCase();
67
+ if (asm.includes('OP_CHECKMULTISIG')) {
68
+ return false;
69
+ }
70
+ if (asm.includes('OP_BOOLOR') && asm.includes('OP_CHECKSIG')) {
71
+ return true;
72
+ }
73
+ // NEW-014: `||` no longer lowers to OP_BOOLOR — it lowers to real
74
+ // OP_IF / OP_ELSE / OP_ENDIF control flow. The NULLFAIL hazard SURVIVES that
75
+ // change: when the FIRST branch fails, its OP_CHECKSIG has already run with a
76
+ // non-empty signature, which is exactly what BIP146 rejects. Short-circuiting
77
+ // only removes the hazard when the first branch succeeds, so this warning
78
+ // must still fire for the branch-shaped form.
79
+ if (asm.includes('OP_IF') && asm.includes('OP_CHECKSIG')) {
80
+ return true;
81
+ }
82
+ // Fall back to hex when ASM is missing: OP_CHECKMULTISIG=0xae, OP_BOOLOR=0x9a
83
+ const hex = (artifact.script ?? artifact.scriptHex ?? '').toLowerCase();
84
+ if (hex.includes('ae') || hex.includes('af')) {
85
+ // May false-positive inside push data; prefer ASM. If ASM empty and we see
86
+ // ae, treat as multi-sig (safe: suppresses warning for MultiSig contracts).
87
+ if (!asm)
88
+ return false;
89
+ }
90
+ return false;
91
+ }
92
+ /**
93
+ * Invalidate the @bsv/sdk Transaction's serialization caches after
94
+ * directly modifying inputs/outputs. The SDK caches toHex()/toBinary()
95
+ * results and only invalidates them through addInput/addOutput.
96
+ */
97
+ function invalidateTxCache(tx) {
98
+ const t = tx;
99
+ t.hexCache = undefined;
100
+ t.rawBytesCache = undefined;
101
+ t.cachedHash = undefined;
102
+ }
103
+ /**
104
+ * The one ByteString parameter name whose `null` argument is auto-resolved to
105
+ * the serialized outpoints of every input (deep-review finding G6). Must match
106
+ * Go's `AutoPrevoutsParamName` and the Rust/Python/Ruby/Zig/Java equivalents —
107
+ * this name is a cross-tier calling convention, not a per-tier ergonomic.
108
+ */
109
+ const AUTO_PREVOUTS_PARAM_NAME = 'allPrevouts';
110
+ /**
111
+ * Decode the value of every EQUALITY `verify_code_part_len` pin in a compiled
112
+ * script.
113
+ *
114
+ * The compiler emits the pin as a fixed-width, unambiguous nine-byte run:
115
+ *
116
+ * 76 | 04 LL LL LL LL | 81 | (9c | a2) | 69
117
+ * OP_DUP <len LE32> OP_BIN2NUM cmp OP_VERIFY
118
+ *
119
+ * `9c` is OP_NUMEQUAL — an exact pin, the only variant a longer code part can
120
+ * violate. `a2` is OP_GREATERTHANOREQUAL, a lower bound that extra bytes
121
+ * satisfy, so it is deliberately not returned here.
122
+ *
123
+ * Read from the emitted TEMPLATE rather than from a built code script: the
124
+ * template holds OP_0 placeholders where constructor args go, so no
125
+ * caller-supplied byte string can be mistaken for a pin.
126
+ */
127
+ function decodeExactCodePartLenPins(scriptHex) {
128
+ const values = [];
129
+ for (let i = 0; i + 18 <= scriptHex.length; i += 2) {
130
+ const seq = scriptHex.slice(i, i + 18);
131
+ if (!seq.startsWith('7604'))
132
+ continue;
133
+ if (seq.slice(12, 14) !== '81')
134
+ continue;
135
+ if (seq.slice(14, 16) !== '9c')
136
+ continue;
137
+ if (seq.slice(16, 18) !== '69')
138
+ continue;
139
+ values.push(parseInt(seq.slice(4, 12).match(/../g).reverse().join(''), 16));
140
+ }
141
+ return values;
142
+ }
143
+ /**
144
+ * Deep-review finding C8: offline dry-run of ONE input of a fully-assembled
145
+ * tx through @bsv/sdk's production `Spend` interpreter (real `OP_CHECKSIG`,
146
+ * real BIP-143). Used by `finalizeCall` to fail closed on a script-invalid
147
+ * (or, via its caller, underfunded) tx BEFORE it reaches `provider.broadcast`.
148
+ *
149
+ * Deliberately self-contained rather than imported from `multi-contract.ts`
150
+ * (which already has an equivalent `dryRunMultiContractInput`): that module
151
+ * imports `encodeArg`/`encodePushData`/`encodeScriptNumber` FROM this file,
152
+ * so importing back from it here would create a contract.ts <-> multi-contract.ts
153
+ * import cycle. Keep both thin wrappers in sync if `Spend`'s constructor shape
154
+ * ever changes.
155
+ */
156
+ function dryRunContractInput(tx, inputIndex, utxoScriptHex, utxoSatoshis) {
157
+ const input = tx.inputs[inputIndex];
158
+ if (!input)
159
+ return { valid: false, error: `no input at index ${inputIndex}` };
160
+ // `otherInputs` feeds BIP-143's hashPrevouts/hashSequence, so what it
161
+ // contains matters: it must be exactly the OTHER inputs (current one
162
+ // excluded), in their original relative order, or the input's
163
+ // OP_CHECKSIG(VERIFY) fails for a perfectly valid transaction — a false
164
+ // rejection, which for a fail-closed pre-broadcast gate is worse than the
165
+ // hole C8 closes. Normalize to exactly the projection the proven harnesses
166
+ // use (`validateSpend` in the SDK spend tests and `runStatefulSpend` in
167
+ // runar-testing's real-crypto oracle, both of which agree with the
168
+ // network).
169
+ //
170
+ // P2 (testing-gap remediation): the per-entry stubbing below
171
+ // (`unlockingScript`/`sourceSatoshis`/`lockingScript`, and each entry's
172
+ // own re-indexed `inputIndex`) is belt-and-braces, not load-bearing for
173
+ // the sighash — `TransactionSignature.formatBip143` (the function `Spend`
174
+ // delegates to) only ever reads `sourceTXID`, `sourceOutputIndex`, and
175
+ // `sequence` off each `otherInputs` entry. Verified equivalent to
176
+ // `providers/mock.ts`'s `validateBroadcastTx`, which passes the raw
177
+ // filtered `tx.inputs` slice straight through with none of this stubbing —
178
+ // both produce an identical sighash as long as the filter excludes exactly
179
+ // the current input and preserves order, which both do. Keep both thin
180
+ // wrappers in sync if `TransactionSignature`'s field usage ever changes.
181
+ const otherInputs = tx.inputs
182
+ .filter((_, i) => i !== inputIndex)
183
+ .map((inp, idx) => ({
184
+ inputIndex: idx >= inputIndex ? idx + 1 : idx,
185
+ sourceOutputIndex: inp.sourceOutputIndex,
186
+ sourceTXID: inp.sourceTXID,
187
+ sequence: inp.sequence,
188
+ unlockingScript: undefined,
189
+ sourceSatoshis: 0,
190
+ lockingScript: LockingScript.fromHex(''),
191
+ }));
192
+ try {
193
+ const spend = new Spend({
194
+ sourceTXID: input.sourceTXID,
195
+ sourceOutputIndex: input.sourceOutputIndex,
196
+ sourceSatoshis: utxoSatoshis,
197
+ lockingScript: LockingScript.fromHex(utxoScriptHex),
198
+ transactionVersion: tx.version,
199
+ otherInputs: otherInputs,
200
+ outputs: tx.outputs,
201
+ inputIndex,
202
+ // NEW-005: `Spend` mutates the script it executes in place, so it must
203
+ // never be handed the live in-flight input's own object — the dry-run
204
+ // would corrupt every evaluation that follows it (including
205
+ // `MockProvider.validateBroadcastTx`, one line later in `finalizeCall`).
206
+ // See spend-safety.ts.
207
+ unlockingScript: detachUnlockingScript(input.unlockingScript),
208
+ inputSequence: input.sequence ?? 0xffffffff,
209
+ lockTime: tx.lockTime,
210
+ });
211
+ // P2: @bsv/sdk's `Spend.validate()` never actually returns `false` —
212
+ // every failure path throws `ScriptEvaluationError` instead, so this
213
+ // line always returns `{ valid: true }` when reached; every rejection
214
+ // exits through the `catch` below. The `!!` is defensive belt-and-
215
+ // braces in case a future @bsv/sdk version reverts to a boolean.
216
+ return { valid: !!spend.validate() };
217
+ }
218
+ catch (e) {
219
+ return { valid: false, error: e instanceof Error ? e.message : String(e) };
220
+ }
221
+ }
222
+ /**
223
+ * Walk a hex-encoded script and return the byte offsets of every
224
+ * OP_CODESEPARATOR (0xab) that sits at a real opcode boundary (i.e. not inside
225
+ * push-data). Correctly skips all BSV push opcodes (0x01..0x4b,
226
+ * OP_PUSHDATA1/2/4).
227
+ *
228
+ * Used by getSubscriptForSigning to recover the true on-chain byte offsets when
229
+ * the in-memory constructor args don't reflect what was actually baked into the
230
+ * locking script (e.g. after fromTxid populates dummy placeholders).
231
+ */
232
+ export function findCodesepOffsets(scriptHex) {
233
+ const out = [];
234
+ let off = 0;
235
+ const n = scriptHex.length;
236
+ const b = (i) => {
237
+ const v = parseInt(scriptHex.slice(i, i + 2), 16);
238
+ return Number.isNaN(v) ? 0 : v;
239
+ };
240
+ while (off + 2 <= n) {
241
+ const op = b(off);
242
+ const bytePos = off / 2;
243
+ if (op === 0xab) {
244
+ out.push(bytePos);
245
+ off += 2;
246
+ }
247
+ else if (op >= 0x01 && op <= 0x4b) {
248
+ off += 2 + op * 2;
249
+ }
250
+ else if (op === 0x4c) {
251
+ if (off + 4 > n)
252
+ break;
253
+ const pushLen = b(off + 2);
254
+ off += 4 + pushLen * 2;
255
+ }
256
+ else if (op === 0x4d) {
257
+ if (off + 6 > n)
258
+ break;
259
+ const lo = b(off + 2);
260
+ const hi = b(off + 4);
261
+ const pushLen = lo | (hi << 8);
262
+ off += 6 + pushLen * 2;
263
+ }
264
+ else if (op === 0x4e) {
265
+ if (off + 10 > n)
266
+ break;
267
+ const b0 = b(off + 2);
268
+ const b1 = b(off + 4);
269
+ const b2 = b(off + 6);
270
+ const b3 = b(off + 8);
271
+ // >>> 0 forces uint32: with b3 >= 0x80 the signed OR result is negative,
272
+ // which would walk the cursor backwards and never terminate the scan.
273
+ const pushLen = (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0;
274
+ // A declared push length past the script end means a malformed script;
275
+ // stop scanning rather than skipping into nothing.
276
+ if (pushLen > (n - off - 10) / 2)
277
+ break;
278
+ off += 10 + pushLen * 2;
279
+ }
280
+ else {
281
+ off += 2;
282
+ }
283
+ }
284
+ return out;
285
+ }
286
+ /**
287
+ * Runtime wrapper for a compiled Runar contract.
288
+ *
289
+ * Handles deployment, method invocation, state tracking, and script
290
+ * construction. Works with any Provider and Signer implementation.
291
+ *
292
+ * ```ts
293
+ * const artifact = JSON.parse(fs.readFileSync('./artifacts/Counter.json', 'utf8'));
294
+ * const contract = new RunarContract(artifact, [0n]); // constructor args
295
+ * const { txid } = await contract.deploy(provider, signer, { satoshis: 10000 });
296
+ * ```
297
+ */
298
+ export class RunarContract {
299
+ artifact;
300
+ /**
301
+ * Constructor arguments for the contract, typed as `unknown[]` because
302
+ * they can be any of the Runar primitive types: `bigint`, `boolean`,
303
+ * `ByteString` (hex string), `PubKey` (hex string), etc. TypeScript
304
+ * generics are not practical here since the types depend on the specific
305
+ * contract being used and are only known at runtime from the ABI.
306
+ */
307
+ constructorArgs;
308
+ _state = {};
309
+ _codeScript = null;
310
+ _inscription = null;
311
+ currentUtxo = null;
312
+ /** Returns the current UTXO tracked by this contract, if any. */
313
+ getUtxo() { return this.currentUtxo; }
314
+ _provider = null;
315
+ _signer = null;
316
+ /**
317
+ * Witness values for intent-covenant intrinsic auto-injected params.
318
+ * `_prevOutScript_<i>` values are stored per-input-index in `_prevOutScripts`;
319
+ * `_serialisedOutputs` is stored in `_serialisedOutputs`. Both are hex strings
320
+ * (normalized in the setters). Read by the call-builder when assembling the
321
+ * unlocking script for methods that use `extractPrevOutputScript` /
322
+ * `requireOutputP2PKH`.
323
+ */
324
+ _prevOutScripts = new Map();
325
+ _serialisedOutputs = null;
326
+ constructor(artifact, constructorArgs) {
327
+ this.artifact = artifact;
328
+ this.constructorArgs = constructorArgs;
329
+ // Validate constructor args match ABI
330
+ const expected = artifact.abi.constructor.params.length;
331
+ if (constructorArgs.length !== expected) {
332
+ throw new Error(`RunarContract: expected ${expected} constructor args for ${artifact.contractName}, got ${constructorArgs.length}`);
333
+ }
334
+ // Initialize state from constructor args for stateful contracts.
335
+ // Properties with initialValue use their default; others are matched
336
+ // to constructor args by name lookup in the ABI constructor params.
337
+ if (artifact.stateFields && artifact.stateFields.length > 0) {
338
+ for (const field of artifact.stateFields) {
339
+ const fa = field.fixedArray;
340
+ if (fa) {
341
+ // FixedArray state field. The assembler stores `initialValue`
342
+ // (when every element has a compile-time default) as a real
343
+ // JS array of element values — no more stringified-tuple
344
+ // parsing. For nested arrays the stored value is a nested
345
+ // JS array mirroring the declared shape. Leaf values may
346
+ // still be bigint-as-string when the artifact was loaded
347
+ // via a plain JSON import without the custom reviver, so
348
+ // walk the tree and revive each leaf.
349
+ const rawInit = field.initialValue;
350
+ if (Array.isArray(rawInit)) {
351
+ this._state[field.name] = reviveNestedValue(rawInit, field.type);
352
+ }
353
+ else if (rawInit !== undefined) {
354
+ // Defensive: we shouldn't hit this path anymore, but if a
355
+ // third-party producer emits a scalar where we expect an
356
+ // array, keep the value as-is instead of crashing.
357
+ this._state[field.name] = rawInit;
358
+ }
359
+ else {
360
+ const paramIdx = artifact.abi.constructor.params.findIndex(p => p.name === field.name);
361
+ if (paramIdx >= 0 && paramIdx < constructorArgs.length) {
362
+ this._state[field.name] = constructorArgs[paramIdx];
363
+ }
364
+ else if (field.index < constructorArgs.length) {
365
+ this._state[field.name] = constructorArgs[field.index];
366
+ }
367
+ }
368
+ continue;
369
+ }
370
+ if (field.initialValue !== undefined) {
371
+ // Property has a compile-time default value.
372
+ // Revive BigInt strings ("0n") that occur when artifacts are loaded
373
+ // via plain JSON import (without the bigintReviver).
374
+ this._state[field.name] = reviveJsonValue(field.initialValue, field.type);
375
+ }
376
+ else {
377
+ // Match by name to constructor params
378
+ const paramIdx = artifact.abi.constructor.params.findIndex(p => p.name === field.name);
379
+ if (paramIdx >= 0 && paramIdx < constructorArgs.length) {
380
+ this._state[field.name] = constructorArgs[paramIdx];
381
+ }
382
+ else if (field.index < constructorArgs.length) {
383
+ // Fallback: use declaration index for backward compatibility
384
+ this._state[field.name] = constructorArgs[field.index];
385
+ }
386
+ }
387
+ }
388
+ }
389
+ }
390
+ // -------------------------------------------------------------------------
391
+ // Connection
392
+ // -------------------------------------------------------------------------
393
+ /**
394
+ * Store a provider and signer on this contract so they don't need to be
395
+ * passed to every `deploy()` and `call()` invocation.
396
+ */
397
+ connect(provider, signer) {
398
+ this._provider = provider;
399
+ this._signer = signer;
400
+ }
401
+ // -------------------------------------------------------------------------
402
+ // Ordinals
403
+ // -------------------------------------------------------------------------
404
+ /**
405
+ * Attach a 1sat ordinals inscription to this contract. The inscription
406
+ * envelope is injected into the locking script between the compiled code
407
+ * and the state section (if any). Once deployed, the inscription is
408
+ * immutable — it persists identically across all state transitions.
409
+ *
410
+ * ```ts
411
+ * contract.withInscription({ contentType: 'image/png', data: pngHex });
412
+ * ```
413
+ *
414
+ * N-043 — THROWS when the envelope would break the contract's own
415
+ * `SIZE(_codePart)` pin. A stateful contract with a variable-length state
416
+ * section carries an equality pin on the deployed code-part length, and the
417
+ * envelope lands INSIDE the code part (see `getCodePartHex`). The compiler
418
+ * bakes that number before any inscription exists, so the pinned length and
419
+ * the real one differ by the envelope's size and every honest spend aborts
420
+ * at OP_VERIFY — with the funds already committed. Refusing here turns a
421
+ * permanent, silent lock into a loud error before a single satoshi moves.
422
+ */
423
+ withInscription(inscription) {
424
+ const previous = this._inscription;
425
+ this._inscription = inscription;
426
+ try {
427
+ this.assertCodePartLengthPinHonoured();
428
+ }
429
+ catch (err) {
430
+ this._inscription = previous;
431
+ throw err;
432
+ }
433
+ return this;
434
+ }
435
+ /**
436
+ * Verify that every equality `verify_code_part_len` pin the compiler baked
437
+ * into this artifact still describes the code part this contract produces.
438
+ *
439
+ * The check is the invariant itself, not a restatement of the compiler's
440
+ * derivation: it decodes the pinned number straight out of the emitted
441
+ * template and compares it to `getCodePartHex()`. So it permits every
442
+ * combination that actually works — a stateless contract or a fixed-size
443
+ * state layout carries no pin at all, and a lower-bound pin is satisfied by
444
+ * a longer code part — and rejects only the shape that would lock funds.
445
+ */
446
+ assertCodePartLengthPinHonoured() {
447
+ const pinned = decodeExactCodePartLenPins(this.artifact.script);
448
+ if (pinned.length === 0)
449
+ return;
450
+ const actual = this.getCodePartHex().length / 2;
451
+ for (const value of pinned) {
452
+ if (value === actual)
453
+ continue;
454
+ throw new Error(`RunarContract.withInscription: ${this.artifact.contractName} pins ` +
455
+ `SIZE(_codePart) == ${value}, but with this inscription attached the ` +
456
+ `code part is ${actual} bytes. Deploying it would make every spend ` +
457
+ `fail OP_VERIFY and lock the contract's funds permanently. An ` +
458
+ `inscription cannot be attached to a stateful contract with a ` +
459
+ `variable-length state section: the envelope is part of the code ` +
460
+ `part, and its length is not known when the pin is compiled.`);
461
+ }
462
+ }
463
+ /** Returns the current inscription, if any. */
464
+ get inscription() {
465
+ return this._inscription;
466
+ }
467
+ /**
468
+ * Resolve provider/signer: explicit args win, then connected, then error.
469
+ */
470
+ resolveProviderSigner(provider, signer) {
471
+ const p = provider ?? this._provider;
472
+ const s = signer ?? this._signer;
473
+ if (!p || !s) {
474
+ throw new Error('No provider/signer available. Call connect() or pass them explicitly.');
475
+ }
476
+ return { provider: p, signer: s };
477
+ }
478
+ async deploy(providerOrOptions, maybeSigner, maybeOptions) {
479
+ let provider;
480
+ let signer;
481
+ let options;
482
+ if (maybeSigner !== undefined && maybeOptions !== undefined) {
483
+ // Explicit: deploy(provider, signer, options)
484
+ provider = providerOrOptions;
485
+ signer = maybeSigner;
486
+ options = maybeOptions;
487
+ }
488
+ else if (typeof providerOrOptions === 'object' &&
489
+ !('getUtxos' in providerOrOptions)) {
490
+ // Connected: deploy(options)
491
+ const resolved = this.resolveProviderSigner();
492
+ provider = resolved.provider;
493
+ signer = resolved.signer;
494
+ options = providerOrOptions;
495
+ }
496
+ else {
497
+ throw new Error('RunarContract.deploy: invalid arguments. Pass (options) or (provider, signer, options).');
498
+ }
499
+ const address = await signer.getAddress();
500
+ const changeAddress = options.changeAddress ?? address;
501
+ const deploySatoshis = options.satoshis ?? 1;
502
+ const lockingScript = this.getLockingScript();
503
+ // DoS-bound: reject pathological scripts BEFORE any signing / broadcast.
504
+ assertScriptHexUnderLimit(lockingScript, InputLimits.MAX_SCRIPT_BYTES, `${this.artifact.contractName}.deploy`);
505
+ // R-062: and refuse to fund a script reaching a builtin the compiler does
506
+ // not claim is sound unless the caller says so here, in the same breath as
507
+ // the money. Same position as the guard above — before any signing or
508
+ // broadcast.
509
+ assertUnsoundPrimitivesAcknowledged(this.artifact, options.acknowledgeUnsound, `${this.artifact.contractName}.deploy`);
510
+ // Fetch fee rate and funding UTXOs
511
+ const feeRate = await provider.getFeeRate();
512
+ const allUtxos = await provider.getUtxos(address);
513
+ if (allUtxos.length === 0) {
514
+ throw new Error(`RunarContract.deploy: no UTXOs found for address ${address}`);
515
+ }
516
+ const utxos = selectUtxos(allUtxos, deploySatoshis, lockingScript.length / 2, feeRate);
517
+ // Build the deploy transaction
518
+ const changeScript = buildP2PKHScript(changeAddress);
519
+ const { tx, inputCount } = buildDeployTransaction(lockingScript, utxos, deploySatoshis, changeAddress, changeScript, feeRate);
520
+ // Sign all inputs — need unsigned hex for signer. Funding inputs are
521
+ // signed by fundingSigner when set (issue #134): the deploy signer may not
522
+ // own the funding coins. Defaults to the connected signer.
523
+ const fundingSigner = options.fundingSigner ?? signer;
524
+ const unsignedHex = tx.toHex();
525
+ for (let i = 0; i < inputCount; i++) {
526
+ const utxo = utxos[i];
527
+ const sig = await fundingSigner.sign(unsignedHex, i, utxo.script, utxo.satoshis);
528
+ const pubKey = await fundingSigner.getPublicKey();
529
+ // Build P2PKH unlocking script: <sig> <pubkey>
530
+ const unlockScript = encodePushData(sig) + encodePushData(pubKey);
531
+ tx.inputs[i].unlockingScript = UnlockingScript.fromHex(unlockScript);
532
+ }
533
+ invalidateTxCache(tx);
534
+ // Broadcast
535
+ const txid = await provider.broadcast(tx);
536
+ // Track the deployed UTXO
537
+ this.currentUtxo = {
538
+ txid,
539
+ outputIndex: 0,
540
+ satoshis: deploySatoshis,
541
+ script: lockingScript,
542
+ };
543
+ const txData = await provider.getTransaction(txid).catch((err) => {
544
+ // Audit finding C4: the fallback reports the transaction this SDK
545
+ // actually broadcast, not an empty `inputs: []` / `outputs: []` shell
546
+ // that reads as a real confirmed tx. The warn stays — this is
547
+ // locally-derived, unconfirmed data.
548
+ console.warn('Failed to fetch transaction after broadcast:', err);
549
+ return txToTransactionData(txid, tx);
550
+ });
551
+ return { txid, tx: txData };
552
+ }
553
+ /**
554
+ * Deploy the contract using a BRC-100 wallet. The wallet owns the coins
555
+ * and creates the transaction itself via `createAction()`.
556
+ *
557
+ * Requires the contract to be connected to a `WalletProvider` (via `connect()`).
558
+ *
559
+ * @param options.satoshis - Satoshis to lock in the contract output (default: 1).
560
+ * @param options.description - Description for the wallet action.
561
+ */
562
+ async deployWithWallet(options = {}) {
563
+ if (!(this._provider instanceof WalletProvider)) {
564
+ throw new Error('deployWithWallet requires a connected WalletProvider. Call connect(walletProvider, signer) first.');
565
+ }
566
+ const walletProvider = this._provider;
567
+ const wallet = walletProvider.walletClient;
568
+ const basket = walletProvider.basketName;
569
+ const lockingScript = this.getLockingScript();
570
+ const satoshis = options.satoshis ?? 1;
571
+ // DoS-bound: reject pathological scripts BEFORE involving the wallet.
572
+ assertScriptHexUnderLimit(lockingScript, InputLimits.MAX_SCRIPT_BYTES, `${this.artifact.contractName}.deployWithWallet`);
573
+ // R-062: the wallet is a SECOND funding path, and it must make the same
574
+ // decision `deploy()` makes — refuse to fund a script reaching a builtin
575
+ // the compiler does not claim is sound unless the caller says so here.
576
+ // Before `createAction`, so no wallet is ever asked for the coins.
577
+ assertUnsoundPrimitivesAcknowledged(this.artifact, options.acknowledgeUnsound, `${this.artifact.contractName}.deployWithWallet`);
578
+ const result = await wallet.createAction({
579
+ description: options.description ?? 'Runar contract deployment',
580
+ outputs: [{
581
+ lockingScript,
582
+ satoshis,
583
+ outputDescription: `Deploy ${this.artifact.contractName}`,
584
+ basket,
585
+ }],
586
+ });
587
+ // Parse BEEF to find the correct vout for our locking script
588
+ let outputIndex = 0;
589
+ let actualSatoshis = satoshis;
590
+ if (result.tx) {
591
+ try {
592
+ const tx = BsvTransaction.fromAtomicBEEF(result.tx);
593
+ for (let i = 0; i < tx.outputs.length; i++) {
594
+ const out = tx.outputs[i];
595
+ if (out.lockingScript?.toHex() === lockingScript) {
596
+ outputIndex = i;
597
+ actualSatoshis = out.satoshis != null ? out.satoshis : satoshis;
598
+ break;
599
+ }
600
+ }
601
+ // Cache raw hex for EF child tx builds
602
+ const txid = result.txid || '';
603
+ if (txid) {
604
+ walletProvider.cacheTx(txid, tx.toHex());
605
+ }
606
+ // Broadcast to ARC (may already be known — non-fatal)
607
+ await walletProvider.broadcast(tx).catch((e) => warnNonFatal('deploy-tx broadcast', e));
608
+ }
609
+ catch (e) {
610
+ warnNonFatal('deploy-tx BEEF parse', e);
611
+ }
612
+ }
613
+ const txid = result.txid || '';
614
+ // Track the deployed UTXO
615
+ this.currentUtxo = {
616
+ txid,
617
+ outputIndex,
618
+ satoshis: actualSatoshis,
619
+ script: lockingScript,
620
+ };
621
+ return { txid, outputIndex };
622
+ }
623
+ async call(methodName, args, providerOrOptions, maybeSigner, maybeOptions) {
624
+ // If explicit provider/signer passed, temporarily connect them for
625
+ // prepareCall / finalizeCall which use the connected references.
626
+ if (maybeSigner !== undefined) {
627
+ const prevProvider = this._provider;
628
+ const prevSigner = this._signer;
629
+ this._provider = providerOrOptions;
630
+ this._signer = maybeSigner;
631
+ try {
632
+ const result = await this.call(methodName, args, maybeOptions);
633
+ return result;
634
+ }
635
+ finally {
636
+ this._provider = prevProvider;
637
+ this._signer = prevSigner;
638
+ }
639
+ }
640
+ let options;
641
+ if (providerOrOptions === undefined ||
642
+ (typeof providerOrOptions === 'object' &&
643
+ !('getUtxos' in providerOrOptions))) {
644
+ options = providerOrOptions;
645
+ }
646
+ else {
647
+ // providerOrOptions looks like a Provider but no signer — try connected
648
+ const prevProvider = this._provider;
649
+ this._provider = providerOrOptions;
650
+ try {
651
+ const result = await this.call(methodName, args, undefined);
652
+ return result;
653
+ }
654
+ finally {
655
+ this._provider = prevProvider;
656
+ }
657
+ }
658
+ const prepared = await this.prepareCall(methodName, args, options);
659
+ const signer = this._signer;
660
+ // Stateful contracts: checkPreimage is auto-injected at method entry, so
661
+ // the user checkSig executes AFTER the OP_CODESEPARATOR — the sighash must
662
+ // be computed over the subscript trimmed at that separator (issue #42: the
663
+ // trim must land at the *real* on-chain codesep byte position, recovered by
664
+ // getSubscriptForSigning's byte-walker).
665
+ // Stateless contracts: the user controls statement order and may place
666
+ // checkSig BEFORE the codesep (e.g. CovenantVault) — those must use the
667
+ // FULL script, so the trim stays gated on the parent class. A stateful
668
+ // contract with ZERO mutable fields (empty stateFields) still injects
669
+ // checkPreimage at entry, so the trim is gated on `_parentStateful`
670
+ // (artifact.parentClass), NOT `_isStateful` (issue #44).
671
+ let mIdx = 0;
672
+ if (prepared._parentStateful) {
673
+ const pubMethods = this.artifact.abi.methods.filter((m) => m.isPublic);
674
+ if (pubMethods.length > 1) {
675
+ const idx = pubMethods.findIndex((m) => m.name === methodName);
676
+ if (idx >= 0)
677
+ mIdx = idx;
678
+ }
679
+ }
680
+ const sigSubscript = prepared._parentStateful
681
+ ? this.getSubscriptForSigning(prepared._contractUtxo.script, mIdx)
682
+ : prepared._contractUtxo.script;
683
+ const signatures = {};
684
+ const txHex = prepared.tx.toHex();
685
+ for (const idx of prepared.sigIndices) {
686
+ signatures[idx] = await signer.sign(txHex, 0, sigSubscript, prepared._contractUtxo.satoshis);
687
+ }
688
+ return this.finalizeCall(prepared, signatures);
689
+ }
690
+ // -------------------------------------------------------------------------
691
+ // prepareCall / finalizeCall — multi-signer support
692
+ // -------------------------------------------------------------------------
693
+ /**
694
+ * Build the transaction for a method call without signing the primary
695
+ * contract input's Sig params. Returns a `PreparedCall` containing the
696
+ * BIP-143 sighash that external signers need, plus opaque internals for
697
+ * `finalizeCall()`.
698
+ *
699
+ * P2PKH funding inputs and additional contract inputs ARE signed with the
700
+ * connected signer. Only the primary contract input's Sig params are left
701
+ * as 72-byte placeholders.
702
+ */
703
+ async prepareCall(methodName, args, options) {
704
+ const { provider, signer } = this.resolveProviderSigner();
705
+ // Funding (and terminal fee) inputs are signed by fundingSigner when set
706
+ // (issue #134). The method's own Sig args stay with the connected signer.
707
+ const fundingSigner = options?.fundingSigner ?? signer;
708
+ const method = this.findMethod(methodName);
709
+ if (!method) {
710
+ throw new Error(`RunarContract.prepareCall: method '${methodName}' not found in ${this.artifact.contractName}`);
711
+ }
712
+ const isStateful = this.artifact.stateFields !== undefined &&
713
+ this.artifact.stateFields.length > 0;
714
+ // `isStateful` (derived from non-empty stateFields) drives continuation
715
+ // params, op_push_tx, change outputs, etc. But a StatefulSmartContract
716
+ // with ZERO mutable fields has empty stateFields yet STILL auto-injects
717
+ // checkPreimage at method entry — so its user checkSig runs AFTER the
718
+ // OP_CODESEPARATOR and needs the issue-#42 subscript trim. The parentClass
719
+ // is the authoritative signal for the trim gate (issue #44). Fall back to
720
+ // `isStateful` for older artifacts that predate the parentClass field.
721
+ const parentStateful = this.artifact.parentClass !== undefined
722
+ ? this.artifact.parentClass === 'StatefulSmartContract'
723
+ : isStateful;
724
+ const methodNeedsChange = method.params.some((p) => p.name === '_changePKH');
725
+ const methodNeedsNewAmount = method.params.some((p) => p.name === '_newAmount');
726
+ // Whether the unlocking script is prefixed with `_codePart`. New artifacts
727
+ // carry the authoritative `usesCodePart` flag (true for continuation
728
+ // builders AND terminal var-length-state readers — issue #100). Older
729
+ // artifacts lack it; fall back to the legacy rule (codePart iff continuation).
730
+ const methodUsesCodePart = method.usesCodePart ?? methodNeedsChange;
731
+ // Drop auto-injected continuation params AND intent-intrinsic witness
732
+ // params (`_prevOutScript_<i>`, `_serialisedOutputs`) from the
733
+ // user-facing arg count check. Witness values come from
734
+ // setPrevOutScript / setSerialisedOutputs, not from the args array.
735
+ const isAutoInjectedWitnessParam = (name) => name.startsWith('_prevOutScript_') || name === '_serialisedOutputs';
736
+ const userParams = isStateful
737
+ ? method.params.filter((p) => p.type !== 'SigHashPreimage' &&
738
+ p.name !== '_changePKH' &&
739
+ p.name !== '_changeAmount' &&
740
+ p.name !== '_newAmount' &&
741
+ !isAutoInjectedWitnessParam(p.name))
742
+ : method.params.filter((p) => !isAutoInjectedWitnessParam(p.name));
743
+ if (userParams.length !== args.length) {
744
+ throw new Error(`RunarContract.prepareCall: method '${methodName}' expects ${userParams.length} args, got ${args.length}`);
745
+ }
746
+ if (!this.currentUtxo) {
747
+ throw new Error('RunarContract.prepareCall: contract is not deployed. Call deploy() or fromTxId() first.');
748
+ }
749
+ // DoS-bound: reject pathological scripts BEFORE any signing / broadcast.
750
+ // Guards the current locking script (existing UTXO) AND the new locking
751
+ // script if this is a stateful continuation, since both cross the wire.
752
+ assertScriptHexUnderLimit(this.currentUtxo.script, InputLimits.MAX_SCRIPT_BYTES, `${this.artifact.contractName}.call(${methodName})`);
753
+ const contractUtxo = { ...this.currentUtxo };
754
+ const address = await signer.getAddress();
755
+ const changeAddress = options?.changeAddress ?? address;
756
+ // Detect auto-compute params (user passed null)
757
+ const sigIndices = [];
758
+ const prevoutsIndices = [];
759
+ let preimageIndex = -1;
760
+ const resolvedArgs = [...args];
761
+ for (let i = 0; i < userParams.length; i++) {
762
+ if (userParams[i].type === 'Sig' && args[i] === null) {
763
+ sigIndices.push(i);
764
+ resolvedArgs[i] = '00'.repeat(72); // placeholder
765
+ }
766
+ if (userParams[i].type === 'PubKey' && args[i] === null) {
767
+ resolvedArgs[i] = await signer.getPublicKey();
768
+ }
769
+ if (userParams[i].type === 'SigHashPreimage' && args[i] === null) {
770
+ preimageIndex = i;
771
+ resolvedArgs[i] = '00'.repeat(181);
772
+ }
773
+ if (userParams[i].type === 'ByteString' && args[i] === null) {
774
+ // Finding G6: this stub used to be TYPE-blind — ANY null ByteString got
775
+ // the allPrevouts outpoint stub, so a caller who passed null for an
776
+ // ordinary ByteString param (say `memo`) silently got real outpoint
777
+ // bytes spliced into their own parameter. That builds a tx which
778
+ // broadcasts and then fails at script execution with an opaque error.
779
+ // Gate on the documented parameter NAME instead: `allPrevouts` keeps
780
+ // its auto-compute convention (used by the token-ft integration tests
781
+ // across tiers), everything else fails loudly at build time naming the
782
+ // offending parameter. Matches Go `isAutoPrevoutsParam`/`errNilNonSigArg`
783
+ // and the Rust/Python/Ruby/Zig/Java equivalents.
784
+ if (userParams[i].name !== AUTO_PREVOUTS_PARAM_NAME) {
785
+ throw new Error(`RunarContract.prepareCall: null arg for ByteString param ` +
786
+ `'${userParams[i].name}' (index ${i}): null is only auto-resolved for ` +
787
+ `Sig (auto-signed), PubKey (taken from the signer), SigHashPreimage, ` +
788
+ `and the '${AUTO_PREVOUTS_PARAM_NAME}' outpoint slot. Pass an explicit ` +
789
+ `value (hex string, or '' for an empty ByteString).`);
790
+ }
791
+ prevoutsIndices.push(i);
792
+ const estimatedInputs = 1 + (options?.additionalContractInputs?.length ?? 0) + 1;
793
+ resolvedArgs[i] = '00'.repeat(36 * estimatedInputs);
794
+ }
795
+ // EMPTY_SIG (issue #106) is intentionally NOT handled here: it is not
796
+ // `null`, so it is never added to `sigIndices` and never signed. It stays
797
+ // in `resolvedArgs` and `encodeArg` emits OP_0 (empty sig) for it.
798
+ }
799
+ // Soft heuristic (issue #106): more than one auto-signed Sig slot on an
800
+ // OR-CHECKSIG method (`checkSig || checkSig` → OP_BOOLOR) usually means
801
+ // the non-matching branch should use EMPTY_SIG — otherwise every branch
802
+ // gets the same real signature and the failing CHECKSIG trips BIP146
803
+ // NULLFAIL on broadcast.
804
+ //
805
+ // Do NOT warn for genuine multi-sig (OP_CHECKMULTISIG / OP_CHECKMULTISIGVERIFY):
806
+ // those require multiple real signatures (AND-style), not EMPTY_SIG.
807
+ if (sigIndices.length >= 2 && isLikelyOrCheckSigMethod(this.artifact)) {
808
+ console.warn(`runar-sdk: ${this.artifact.contractName}.call('${methodName}') has ` +
809
+ `${sigIndices.length} auto-signed Sig slots. If this is an OR-CHECKSIG ` +
810
+ `method, pass EMPTY_SIG for the non-matching branch(es) to satisfy ` +
811
+ `BIP146 NULLFAIL (issue #106).`);
812
+ }
813
+ const needsOpPushTx = preimageIndex >= 0 || isStateful;
814
+ // Compute method selector and method index (needed for both terminal and non-terminal)
815
+ let methodSelectorHex = '';
816
+ let methodIndex = 0;
817
+ if (isStateful) {
818
+ const publicMethods = this.artifact.abi.methods.filter((m) => m.isPublic);
819
+ if (publicMethods.length > 1) {
820
+ const idx = publicMethods.findIndex((m) => m.name === methodName);
821
+ if (idx >= 0) {
822
+ methodSelectorHex = encodeScriptNumber(BigInt(idx));
823
+ methodIndex = idx;
824
+ }
825
+ }
826
+ }
827
+ // Compute change PKH for stateful methods that need it
828
+ let changePKHHex = '';
829
+ if (isStateful && methodNeedsChange) {
830
+ const changePubKeyHex = options?.changePubKey ?? await signer.getPublicKey();
831
+ const pubKeyBytes = Utils.toArray(changePubKeyHex, 'hex');
832
+ const hash160Bytes = Hash.hash160(pubKeyBytes);
833
+ changePKHHex = Utils.toHex(hash160Bytes);
834
+ }
835
+ // Pre-resolve intent-intrinsic witness hex (throws WitnessValueMissingError
836
+ // if a `_prevOutScript_<i>` or `_serialisedOutputs` param wasn't set on the
837
+ // contract). Resolving up-front means the error is raised BEFORE any
838
+ // signing / broadcast work, mirroring the deploy/call script-size guard.
839
+ const witnessHex = this.buildIntentWitnessHex(method);
840
+ // -------------------------------------------------------------------
841
+ // Terminal method path
842
+ // -------------------------------------------------------------------
843
+ if (options?.terminalOutputs) {
844
+ const terminalOutputs = options.terminalOutputs;
845
+ let termUnlockScript;
846
+ if (needsOpPushTx) {
847
+ termUnlockScript = encodePushData('00'.repeat(72)) +
848
+ this.buildUnlockingScript(methodName, resolvedArgs);
849
+ }
850
+ else {
851
+ termUnlockScript = this.buildUnlockingScript(methodName, resolvedArgs);
852
+ }
853
+ // Witness values are appended to the size-estimation script too so
854
+ // the BIP-143 preimage sees the same input weight that the final
855
+ // stateful unlock will produce (the real final unlock is built by
856
+ // `buildUnlock` below which inserts witnessHex in the correct ABI slot).
857
+ termUnlockScript += witnessHex;
858
+ // Sequence (issue #131): all-final inputs make nLockTime a consensus
859
+ // no-op — when a non-zero locktime is set, default to 0xfffffffe so the
860
+ // terminal method's extractLocktime assertion is actually enforced.
861
+ const termSequence = resolveInputSequence(options?.locktime, options?.sequence);
862
+ const buildTerminalTx = (unlock) => {
863
+ const ttx = new BsvTransaction();
864
+ // Terminal calls (auction close/claim/withdraw) typically assert
865
+ // `extractLocktime(preimage) >= deadline`. Default 0 preserves legacy
866
+ // behavior for contracts that don't check locktime.
867
+ ttx.lockTime = options?.locktime ?? 0;
868
+ ttx.addInput({
869
+ sourceTXID: contractUtxo.txid,
870
+ sourceOutputIndex: contractUtxo.outputIndex,
871
+ unlockingScript: UnlockingScript.fromHex(unlock),
872
+ sequence: termSequence,
873
+ });
874
+ // Fee input (issue #118): a plain P2PKH input added BEFORE the
875
+ // OP_PUSH_TX preimage is computed so hashPrevouts covers it. Consumed
876
+ // entirely as fee (no change output) — the covenant's terminal output
877
+ // assertions are untouched; only the input side grows. Its P2PKH sig is
878
+ // filled in after the tx structure is final (below).
879
+ if (options?.feeUtxo) {
880
+ ttx.addInput({
881
+ sourceTXID: options.feeUtxo.txid,
882
+ sourceOutputIndex: options.feeUtxo.outputIndex,
883
+ unlockingScript: new UnlockingScript(),
884
+ sequence: termSequence,
885
+ });
886
+ }
887
+ for (const out of terminalOutputs) {
888
+ ttx.addOutput({
889
+ satoshis: out.satoshis,
890
+ lockingScript: LockingScript.fromHex(out.scriptHex),
891
+ });
892
+ }
893
+ return ttx;
894
+ };
895
+ let termTx = buildTerminalTx(termUnlockScript);
896
+ let finalOpPushTxSig = '';
897
+ let finalPreimage = '';
898
+ if (isStateful) {
899
+ // Build stateful terminal unlock with PLACEHOLDER user sigs
900
+ const buildUnlock = (tx) => {
901
+ const { sigHex: opSig, preimageHex: preimage } = this.computeOpPushTxWithCodeSep(tx, 0, contractUtxo.script, contractUtxo.satoshis, methodIndex);
902
+ let argsHex = '';
903
+ for (const arg of resolvedArgs)
904
+ argsHex += encodeArg(arg);
905
+ let changeHex = '';
906
+ if (methodNeedsChange && changePKHHex) {
907
+ changeHex = encodePushData(changePKHHex) + encodeArg(0n);
908
+ }
909
+ let newAmountHex = '';
910
+ if (methodNeedsNewAmount) {
911
+ newAmountHex = encodeArg(BigInt(contractUtxo.satoshis));
912
+ }
913
+ const unlock = this.buildStatefulPrefix(opSig, methodUsesCodePart) + argsHex + changeHex + newAmountHex + encodePushData(preimage) + witnessHex + methodSelectorHex;
914
+ return { unlock, opSig, preimage };
915
+ };
916
+ // First pass
917
+ const first = buildUnlock(termTx);
918
+ termTx = buildTerminalTx(first.unlock);
919
+ // Second pass
920
+ const second = buildUnlock(termTx);
921
+ termTx.inputs[0].unlockingScript = UnlockingScript.fromHex(second.unlock);
922
+ invalidateTxCache(termTx);
923
+ finalOpPushTxSig = second.opSig;
924
+ finalPreimage = second.preimage;
925
+ }
926
+ else if (needsOpPushTx || sigIndices.length > 0) {
927
+ // Stateless terminal — keep placeholder sigs
928
+ if (needsOpPushTx) {
929
+ const { sigHex, preimageHex } = this.computeOpPushTxWithCodeSep(termTx, 0, contractUtxo.script, contractUtxo.satoshis, methodIndex);
930
+ finalOpPushTxSig = sigHex;
931
+ resolvedArgs[preimageIndex] = preimageHex;
932
+ }
933
+ // Don't sign Sig params — keep 72-byte placeholders
934
+ let realUnlock = this.buildUnlockingScript(methodName, resolvedArgs);
935
+ if (needsOpPushTx && finalOpPushTxSig) {
936
+ realUnlock = this.buildStatefulPrefix(finalOpPushTxSig) + realUnlock;
937
+ termTx.inputs[0].unlockingScript = UnlockingScript.fromHex(realUnlock);
938
+ invalidateTxCache(termTx);
939
+ const { sigHex: finalSig, preimageHex: finalPre } = this.computeOpPushTxWithCodeSep(termTx, 0, contractUtxo.script, contractUtxo.satoshis, methodIndex);
940
+ resolvedArgs[preimageIndex] = finalPre;
941
+ finalOpPushTxSig = finalSig;
942
+ finalPreimage = finalPre;
943
+ realUnlock = this.buildStatefulPrefix(finalSig) +
944
+ this.buildUnlockingScript(methodName, resolvedArgs);
945
+ }
946
+ termTx.inputs[0].unlockingScript = UnlockingScript.fromHex(realUnlock);
947
+ invalidateTxCache(termTx);
948
+ if (!finalPreimage && needsOpPushTx) {
949
+ finalPreimage = resolvedArgs[preimageIndex];
950
+ }
951
+ }
952
+ // Sign the fee input (issue #118). Its BIP-143 P2PKH sighash covers only
953
+ // hashPrevouts / hashOutputs / its own outpoint — NOT input 0's scriptSig
954
+ // — so it stays valid even after finalizeCall rewrites input 0. Owned by
955
+ // fundingSigner ?? signer (composes with #134). The fee input sits at
956
+ // index 1 (right after the primary contract input).
957
+ if (options?.feeUtxo) {
958
+ const feeInputIdx = 1;
959
+ const feeTxHex = termTx.toHex();
960
+ const feeSig = await fundingSigner.sign(feeTxHex, feeInputIdx, options.feeUtxo.script, options.feeUtxo.satoshis);
961
+ const feePubKey = await fundingSigner.getPublicKey();
962
+ termTx.inputs[feeInputIdx].unlockingScript = UnlockingScript.fromHex(encodePushData(feeSig) + encodePushData(feePubKey));
963
+ invalidateTxCache(termTx);
964
+ // #118: a feeUtxo is consumed ENTIRELY as fee — there is no change
965
+ // output, because the covenant binds the exact terminal output set.
966
+ // An oversized feeUtxo therefore silently BURNS the excess. Warn (but
967
+ // never block) when it dwarfs the terminal tx's estimated fee.
968
+ // Heuristic: excess is burned if the feeUtxo is > 5x the estimated fee
969
+ // AND at least ~1000 sats of excess would be burned (an absolute floor
970
+ // so a slightly-generous fee on a tiny tx does not nag). Best-effort:
971
+ // any failure fetching the fee rate skips the advisory silently.
972
+ try {
973
+ const feeRate = await provider.getFeeRate(); // sat/KB
974
+ const termTxSizeBytes = termTx.toHex().length / 2;
975
+ const estimatedFee = Math.max(1, Math.ceil((termTxSizeBytes * feeRate) / 1000));
976
+ const excess = options.feeUtxo.satoshis - estimatedFee;
977
+ const OVERSIZE_FEE_MULTIPLE = 5;
978
+ const OVERSIZE_MIN_EXCESS_SATS = 1000;
979
+ if (options.feeUtxo.satoshis > estimatedFee * OVERSIZE_FEE_MULTIPLE &&
980
+ excess > OVERSIZE_MIN_EXCESS_SATS) {
981
+ console.warn(`runar-sdk: ${this.artifact.contractName}.call('${methodName}'): feeUtxo is ` +
982
+ `${options.feeUtxo.satoshis} sats but the terminal tx needs only ~${estimatedFee} sats ` +
983
+ `of fee. A feeUtxo is consumed ENTIRELY as fee (no change output — the covenant binds ` +
984
+ `the exact terminal outputs), so ~${excess} sats will be BURNED. Size the feeUtxo close ` +
985
+ `to the intended fee (issue #118).`);
986
+ }
987
+ }
988
+ catch {
989
+ // Advisory only — never fail a call because the fee-rate lookup threw.
990
+ }
991
+ }
992
+ // Compute sighash from preimage (C19: the true BIP-143 digest external
993
+ // signers must ECDSA-sign — hash256(preimage), not sha256(preimage)).
994
+ const sighash = finalPreimage ? computeBip143Sighash(finalPreimage) : '';
995
+ return {
996
+ sighash,
997
+ preimage: finalPreimage,
998
+ opPushTxSig: finalOpPushTxSig,
999
+ tx: termTx,
1000
+ sigIndices,
1001
+ _methodName: methodName,
1002
+ _resolvedArgs: resolvedArgs,
1003
+ _methodSelectorHex: methodSelectorHex,
1004
+ _isStateful: isStateful,
1005
+ _parentStateful: parentStateful,
1006
+ _isTerminal: true,
1007
+ _needsOpPushTx: needsOpPushTx,
1008
+ _methodNeedsChange: methodNeedsChange,
1009
+ _methodUsesCodePart: methodUsesCodePart,
1010
+ _changePKHHex: changePKHHex,
1011
+ _changeAmount: 0,
1012
+ _methodNeedsNewAmount: false,
1013
+ _newAmount: 0,
1014
+ _preimageIndex: preimageIndex,
1015
+ _contractUtxo: contractUtxo,
1016
+ _newLockingScript: '',
1017
+ _newSatoshis: 0,
1018
+ _hasMultiOutput: false,
1019
+ _contractOutputs: [],
1020
+ _intentWitnessHex: witnessHex,
1021
+ _dryRun: options?.dryRun ?? false,
1022
+ };
1023
+ }
1024
+ // -------------------------------------------------------------------
1025
+ // Non-terminal path
1026
+ // -------------------------------------------------------------------
1027
+ // Build the initial unlocking script (with placeholders). Intent-witness
1028
+ // hex is suffixed so size estimation (and any downstream
1029
+ // assertScriptHexUnderLimit / fee math) accounts for the witness pushes.
1030
+ // The real ABI-correct unlock is rebuilt by buildStatefulUnlock below for
1031
+ // stateful methods.
1032
+ let unlockingScript;
1033
+ if (needsOpPushTx) {
1034
+ unlockingScript = encodePushData('00'.repeat(72)) +
1035
+ this.buildUnlockingScript(methodName, resolvedArgs) + witnessHex;
1036
+ }
1037
+ else {
1038
+ unlockingScript = this.buildUnlockingScript(methodName, resolvedArgs) + witnessHex;
1039
+ }
1040
+ let newLockingScript;
1041
+ let newSatoshis;
1042
+ let contractOutputs;
1043
+ const extraContractUtxos = options?.additionalContractInputs ?? [];
1044
+ const hasMultiOutput = options?.outputs && options.outputs.length > 0;
1045
+ // Data outputs declared via this.addDataOutput(...). Explicit
1046
+ // options.dataOutputs wins; otherwise populated by the ANF
1047
+ // interpreter pass below.
1048
+ let resolvedDataOutputs = options?.dataOutputs
1049
+ ? options.dataOutputs.map((d) => ({ script: d.script, satoshis: Number(d.satoshis) }))
1050
+ : [];
1051
+ // Always run the ANF interpreter on stateful artifacts so addDataOutput
1052
+ // payloads are extracted even when the caller pre-supplied newState.
1053
+ // newState only overrides the state continuation; data outputs are
1054
+ // method-body behaviour and the on-chain continuation hash check fails
1055
+ // at spend time if they're omitted. Mirrors Go SDK + Ruby SDK.
1056
+ let autoComputedState;
1057
+ let autoFlatState;
1058
+ // State-class outputs (state continuation + raw) in source order, from the
1059
+ // ANF interpreter. Empty for methods that emit no `this.addRawOutput(...)`
1060
+ // (finding G1) — the raw-output branch below is a no-op in that case.
1061
+ let anfOrderedOutputs = [];
1062
+ if (isStateful && this.artifact.anf) {
1063
+ const namedArgs = buildNamedArgs(userParams, resolvedArgs);
1064
+ const flatState = flattenFixedArrayState(this._state, this.artifact.stateFields);
1065
+ const flatCtorArgs = flattenFixedArrayArgs(this.constructorArgs, this.artifact.abi.constructor.params);
1066
+ try {
1067
+ const { state: computed, dataOutputs: anfDataOutputs, outputs: anfOutputs } = computeNewStateAndDataOutputs(this.artifact.anf, methodName, flatState, namedArgs, flatCtorArgs);
1068
+ autoComputedState = computed;
1069
+ autoFlatState = flatState;
1070
+ anfOrderedOutputs = anfOutputs;
1071
+ if (anfDataOutputs.length > 0 && resolvedDataOutputs.length === 0) {
1072
+ resolvedDataOutputs = anfDataOutputs.map((d) => ({
1073
+ script: d.script,
1074
+ satoshis: Number(d.satoshis),
1075
+ }));
1076
+ }
1077
+ }
1078
+ catch (err) {
1079
+ // FAIL CLOSED (NEW-006). The legacy behaviour was to swallow this and
1080
+ // build the continuation from the CURRENT state, which the covenant's
1081
+ // hashOutputs binding then rejects — a silent "your call cannot be
1082
+ // broadcast", plus silent loss of the method's data / raw outputs.
1083
+ // The interpreter is the only thing that knows this method's post-state
1084
+ // and its addDataOutput/addRawOutput payloads, so there is nothing to
1085
+ // fall back TO: an explicit `newState` covers only the state field and
1086
+ // still leaves the outputs missing.
1087
+ throw new Error(`RunarContract.call('${methodName}'): the ANF interpreter could not evaluate ` +
1088
+ `the method body, so the state continuation and data outputs this call would ` +
1089
+ `commit cannot be derived. Refusing to broadcast a transaction built from the ` +
1090
+ `pre-call state. Cause: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1091
+ }
1092
+ }
1093
+ if (isStateful && hasMultiOutput) {
1094
+ const codeScript = this._codeScript ?? this.buildCodeScript();
1095
+ contractOutputs = options.outputs.map((out) => {
1096
+ const stateHex = serializeState(this.artifact.stateFields, out.state);
1097
+ return { script: codeScript + '6a' + stateHex, satoshis: out.satoshis ?? 1 };
1098
+ });
1099
+ }
1100
+ else if (isStateful) {
1101
+ // Honor an explicit `this.addOutput(<sats>, ...)` state continuation: the
1102
+ // ANF interpreter records that amount in `anfOrderedOutputs` (one
1103
+ // `kind:'state'` entry per addOutput). A method with a single explicit
1104
+ // addOutput and no raw output must build its continuation at that amount,
1105
+ // not default to the spent input's value — otherwise the covenant's
1106
+ // hashOutputs binding rejects the spend and funds are stranded. Finding G1
1107
+ // reads the same satoshis but only on the raw-output-present branch below;
1108
+ // this generalizes it to the no-raw single-continuation path. With no
1109
+ // explicit addOutput (the auto-injected continuation) `anfOrderedOutputs`
1110
+ // is empty and the input-value default is kept.
1111
+ const anfStateEntries = anfOrderedOutputs.filter((o) => o.kind === 'state');
1112
+ const singleExplicitStateSats = anfOrderedOutputs.length === 1 && anfStateEntries.length === 1
1113
+ ? Number(anfStateEntries[0].satoshis)
1114
+ : undefined;
1115
+ newSatoshis = options?.satoshis ?? singleExplicitStateSats ?? this.currentUtxo.satoshis;
1116
+ if (options?.newState) {
1117
+ // Explicit newState takes priority (backward compat)
1118
+ this._state = { ...this._state, ...options.newState };
1119
+ }
1120
+ else if (methodNeedsChange && autoComputedState && autoFlatState) {
1121
+ const merged = { ...autoFlatState, ...autoComputedState };
1122
+ const regrouped = regroupFixedArrayState(merged, this.artifact.stateFields);
1123
+ this._state = { ...this._state, ...regrouped };
1124
+ }
1125
+ newLockingScript = this.getLockingScript();
1126
+ // DoS-bound: also reject pathological continuation scripts BEFORE broadcast.
1127
+ assertScriptHexUnderLimit(newLockingScript, InputLimits.MAX_SCRIPT_BYTES, `${this.artifact.contractName}.call(${methodName}).continuation`);
1128
+ }
1129
+ // Finding G1: a method that calls `this.addRawOutput(...)` folds the raw
1130
+ // output(s) into the covenant's continuation hashOutputs IN SOURCE ORDER,
1131
+ // interleaved with the state continuation `this.addOutput(...)`. The
1132
+ // single-stateful branch above only builds the state continuation, so the
1133
+ // built tx's outputs would mismatch hashOutputs and input 0's OP_VERIFY
1134
+ // would reject. Rebuild an ORDERED `contractOutputs` from the interpreter's
1135
+ // source-ordered output list. Purely additive: absent raw outputs this is a
1136
+ // no-op and existing behaviour is untouched.
1137
+ if (isStateful && anfOrderedOutputs.some((o) => o.kind === 'raw')) {
1138
+ const stateEntries = anfOrderedOutputs.filter((o) => o.kind === 'state');
1139
+ // Fail closed. The current SDK only builds raw outputs alongside a SINGLE
1140
+ // state continuation (the covenant machinery below threads exactly one
1141
+ // newLockingScript/newAmount). Multi-output calls, multiple continuations,
1142
+ // or a missing continuation script are not representable — throw rather
1143
+ // than silently drop outputs and strand the funds.
1144
+ if (hasMultiOutput || stateEntries.length >= 2 || (stateEntries.length === 1 && !newLockingScript)) {
1145
+ throw new Error(`RunarContract.call('${methodName}'): cannot build a transaction that interleaves ` +
1146
+ `raw outputs with ${stateEntries.length} state continuations; the SDK currently ` +
1147
+ `supports raw outputs alongside a single state continuation only (finding G1).`);
1148
+ }
1149
+ contractOutputs = anfOrderedOutputs.map((o) => o.kind === 'raw'
1150
+ ? { script: o.script, satoshis: Number(o.satoshis) }
1151
+ : { script: newLockingScript, satoshis: Number(o.satoshis) });
1152
+ // Keep the preimage's newAmount (buildStatefulUnlock) in step with the
1153
+ // continuation output's sats — `this.addOutput(0n, ...)` makes it 0, not
1154
+ // the input value the single-stateful branch defaulted to.
1155
+ newSatoshis = stateEntries.length === 1 ? Number(stateEntries[0].satoshis) : newSatoshis;
1156
+ }
1157
+ const feeRate = await provider.getFeeRate();
1158
+ const changeScript = buildP2PKHScript(changeAddress);
1159
+ const allFundingUtxos = await provider.getUtxos(address);
1160
+ const candidateFundingUtxos = allFundingUtxos.filter((u) => !(u.txid === this.currentUtxo.txid && u.outputIndex === this.currentUtxo.outputIndex));
1161
+ // Resolve per-input args for additional contract inputs. Hoisted ABOVE the
1162
+ // funding coin-selection (was below it) so the merge unlock placeholders —
1163
+ // and therefore the contract-input byte size the funding fee must cover —
1164
+ // are known before we size the funding. Consumed again later when the real
1165
+ // merge unlocks are built.
1166
+ const resolvedPerInputArgs = options?.additionalContractInputArgs
1167
+ ? options.additionalContractInputArgs.map((inputArgs) => {
1168
+ const resolved = [...inputArgs];
1169
+ for (let i = 0; i < userParams.length; i++) {
1170
+ if (userParams[i].type === 'Sig' && resolved[i] === null) {
1171
+ resolved[i] = '00'.repeat(72);
1172
+ }
1173
+ if (userParams[i].type === 'PubKey' && resolved[i] === null) {
1174
+ resolved[i] = resolvedArgs[userParams.findIndex((p) => p.type === 'PubKey')];
1175
+ }
1176
+ if (userParams[i].type === 'ByteString' && resolved[i] === null) {
1177
+ const estimatedInputs = 1 + (options?.additionalContractInputs?.length ?? 0) + 1;
1178
+ resolved[i] = '00'.repeat(36 * estimatedInputs);
1179
+ }
1180
+ }
1181
+ return resolved;
1182
+ })
1183
+ : undefined;
1184
+ // Build placeholder unlocking scripts for merge inputs (witnessHex
1185
+ // suffixed for sizing — buildStatefulUnlock builds the real scripts).
1186
+ const extraUnlockPlaceholders = extraContractUtxos.map((_, i) => {
1187
+ const argsForPlaceholder = resolvedPerInputArgs?.[i] ?? resolvedArgs;
1188
+ return encodePushData('00'.repeat(72)) + this.buildUnlockingScript(methodName, argsForPlaceholder) + witnessHex;
1189
+ });
1190
+ // Coin selection for funding inputs (issue #133): don't sweep the whole
1191
+ // wallet. Compute how much the funding must cover — the contract's own
1192
+ // input value already offsets the contract/data outputs — and pick the
1193
+ // smallest largest-first set via selectUtxos (the strategy deploy uses).
1194
+ const contractOutputSats = (contractOutputs
1195
+ ? contractOutputs.reduce((sum, o) => sum + o.satoshis, 0)
1196
+ : (newSatoshis ?? 0))
1197
+ + resolvedDataOutputs.reduce((sum, o) => sum + o.satoshis, 0);
1198
+ const contractInputSats = this.currentUtxo.satoshis
1199
+ + extraContractUtxos.reduce((sum, u) => sum + u.satoshis, 0);
1200
+ const fundingTarget = Math.max(0, contractOutputSats - contractInputSats);
1201
+ // Fee sizing hint for selectUtxos: the continuation script length (falls
1202
+ // back to the first multi-output script, else 0 for stateless calls).
1203
+ const fundingLockLen = (newLockingScript?.length ?? contractOutputs?.[0]?.script.length ?? 0) / 2;
1204
+ // Contract-input unlock bytes. selectUtxos/estimateDeployFee otherwise model
1205
+ // ONLY the funding inputs (148-byte P2PKH each) + continuation + change —
1206
+ // they are blind to the contract input(s) being spent. For a MERGE each
1207
+ // covenant input embeds both parent txs as method args (tens of KB), so
1208
+ // ignoring them under-provisions the funding; buildCallTransaction then sees
1209
+ // change <= 0 and DROPS the change output, and the merge covenant — which
1210
+ // reconstructs [continuation][P2PKH change] UNCONDITIONALLY — fails its
1211
+ // hashOutputs OP_VERIFY. Size the funding fee against the SAME serialized
1212
+ // per-input bytes buildCallTransaction uses (32 outpoint + 4 index + varint +
1213
+ // script + 4 sequence) for the primary contract input plus every extra one.
1214
+ // Over-estimating is safe (a little more funding / higher change); under-
1215
+ // estimating is the bug. Small ops (single covenant input, tiny unlock) add
1216
+ // a correspondingly small term and are unaffected.
1217
+ const perInputBytes = (unlockHex) => {
1218
+ const len = unlockHex.length / 2;
1219
+ const vi = len < 0xfd ? 1 : len <= 0xffff ? 3 : len <= 0xffffffff ? 5 : 9;
1220
+ return 32 + 4 + vi + len + 4;
1221
+ };
1222
+ // The `unlockingScript` / `extraUnlockPlaceholders` above are SIZING
1223
+ // placeholders: `encodePushData(sig72) + buildUnlockingScript(args) +
1224
+ // witnessHex`. For a stateful call the REAL unlock buildStatefulUnlock emits
1225
+ // is larger — it prepends the opSig codePart prefix and appends the BIP-143
1226
+ // preimage (whose scriptCode ≈ the locking script), the change + new-amount
1227
+ // pushes, and the method selector. That gap (~6.5 KB per input for the EAC
1228
+ // merge) is exactly what would make selectUtxos stop one UTXO short, leaving
1229
+ // change <= 0 so the covenant's [continuation][change] reconstruction fails.
1230
+ // Add a per-contract-input overestimate covering those omitted components:
1231
+ // codePart (≈ locking script) + preimage scriptCode (≈ locking script) + a
1232
+ // fixed buffer for sig/amount/selector/varint framing. Over-estimating only
1233
+ // pulls slightly more funding (bigger change) — always safe.
1234
+ const numContractInputs = 1 + extraContractUtxos.length;
1235
+ const perContractInputOverhead = 2 * Math.ceil(fundingLockLen) + 512;
1236
+ const contractInputBytes = perInputBytes(unlockingScript)
1237
+ + extraUnlockPlaceholders.reduce((sum, u) => sum + perInputBytes(u), 0)
1238
+ + numContractInputs * perContractInputOverhead;
1239
+ // C15: size the funding fee against ALL outputs, not just the single
1240
+ // continuation that `fundingLockLen` already covers. estimateDeployFee counts
1241
+ // one output of `fundingLockLen` bytes; add the framing (8 + varint +
1242
+ // scriptLen) of every OTHER contract output (extra multi-outputs + raw
1243
+ // outputs, finding G1) and every data output so selection does not stop one
1244
+ // UTXO short on multi-output / large-dataOutput calls. Single-output calls
1245
+ // net 0 (estimate unchanged). Over-estimating only pulls slightly more
1246
+ // funding (bigger change) — always safe.
1247
+ const outputFraming = (byteLen) => {
1248
+ const vi = byteLen < 0xfd ? 1 : byteLen <= 0xffff ? 3 : byteLen <= 0xffffffff ? 5 : 9;
1249
+ return 8 + vi + byteLen;
1250
+ };
1251
+ const allOutputByteLens = [
1252
+ ...(contractOutputs
1253
+ ? contractOutputs.map((o) => o.script.length / 2)
1254
+ : newLockingScript ? [newLockingScript.length / 2] : []),
1255
+ ...resolvedDataOutputs.map((o) => o.script.length / 2),
1256
+ ];
1257
+ const totalOutputFraming = allOutputByteLens.reduce((s, n) => s + outputFraming(n), 0);
1258
+ const extraOutputBytes = Math.max(0, totalOutputFraming - outputFraming(Math.ceil(fundingLockLen)));
1259
+ const additionalUtxos = candidateFundingUtxos.length > 0
1260
+ ? selectUtxos(candidateFundingUtxos, fundingTarget, fundingLockLen, feeRate, contractInputBytes, extraOutputBytes)
1261
+ : [];
1262
+ // Cap funding inputs when the caller sets maxFundingInputs. selectUtxos
1263
+ // returns the minimal largest-first set; if that still exceeds the cap the
1264
+ // funding can't cover outputs + fee within the budget, so fail loudly
1265
+ // instead of broadcasting an underfunded tx.
1266
+ if (options?.maxFundingInputs !== undefined &&
1267
+ additionalUtxos.length > options.maxFundingInputs) {
1268
+ throw new Error(`RunarContract.call(${methodName}): funding requires ${additionalUtxos.length} input(s) ` +
1269
+ `but maxFundingInputs=${options.maxFundingInputs}. Increase maxFundingInputs, ` +
1270
+ `use larger UTXOs, or consolidate.`);
1271
+ }
1272
+ // (resolvedPerInputArgs + extraUnlockPlaceholders are computed above, before
1273
+ // funding coin-selection, so their byte sizes feed the funding fee estimate.)
1274
+ let { tx, inputCount, changeAmount } = buildCallTransaction(this.currentUtxo, unlockingScript, newLockingScript, newSatoshis, changeAddress, changeScript, additionalUtxos.length > 0 ? additionalUtxos : undefined, feeRate, {
1275
+ contractOutputs,
1276
+ additionalContractInputs: extraContractUtxos.length > 0
1277
+ ? extraContractUtxos.map((utxo, i) => ({ utxo, unlockingScript: extraUnlockPlaceholders[i] }))
1278
+ : undefined,
1279
+ dataOutputs: resolvedDataOutputs.length > 0 ? resolvedDataOutputs : undefined,
1280
+ // Thread CallOptions.locktime so contracts asserting
1281
+ // extractLocktime(preimage) can succeed. Default unset → 0.
1282
+ locktime: options?.locktime,
1283
+ // Thread CallOptions.sequence (issue #131): a non-zero locktime needs
1284
+ // non-final input sequences or consensus ignores nLockTime.
1285
+ sequence: options?.sequence,
1286
+ });
1287
+ // Sign P2PKH funding inputs (with fundingSigner — issue #134)
1288
+ let txHex = tx.toHex();
1289
+ const p2pkhStartIdx = 1 + extraContractUtxos.length;
1290
+ for (let i = p2pkhStartIdx; i < inputCount; i++) {
1291
+ const utxo = additionalUtxos[i - p2pkhStartIdx];
1292
+ if (utxo) {
1293
+ const sig = await fundingSigner.sign(txHex, i, utxo.script, utxo.satoshis);
1294
+ const pubKey = await fundingSigner.getPublicKey();
1295
+ const unlockScript = encodePushData(sig) + encodePushData(pubKey);
1296
+ tx.inputs[i].unlockingScript = UnlockingScript.fromHex(unlockScript);
1297
+ invalidateTxCache(tx);
1298
+ txHex = tx.toHex();
1299
+ }
1300
+ }
1301
+ let finalOpPushTxSig = '';
1302
+ let finalPreimage = '';
1303
+ if (isStateful) {
1304
+ const perInputArgs = options?.additionalContractInputArgs;
1305
+ // Helper: build a stateful unlock. For inputIdx===0 (primary), keeps
1306
+ // placeholder Sig params. For inputIdx>0 (extra), signs with signer.
1307
+ const buildStatefulUnlock = async (currentTx, inputIdx, subscript, sats, argsOverride, txChangeAmount) => {
1308
+ const { sigHex: opSig, preimageHex: preimage } = this.computeOpPushTxWithCodeSep(currentTx, inputIdx, subscript, sats, methodIndex);
1309
+ const baseArgs = argsOverride ?? resolvedArgs;
1310
+ const inputArgs = [...baseArgs];
1311
+ // Only sign Sig params for extra inputs, not the primary
1312
+ if (inputIdx > 0) {
1313
+ // Stateful: user checkSig is AFTER OP_CODESEPARATOR — use trimmed script.
1314
+ const trimmedSubscript = this.getSubscriptForSigning(subscript, methodIndex);
1315
+ const currentHex = currentTx.toHex();
1316
+ for (const idx of sigIndices) {
1317
+ inputArgs[idx] = await signer.sign(currentHex, inputIdx, trimmedSubscript, sats);
1318
+ }
1319
+ }
1320
+ if (prevoutsIndices.length > 0) {
1321
+ let allPrevoutsHex = '';
1322
+ for (const inp of currentTx.inputs) {
1323
+ const txidLE = inp.sourceTXID.match(/.{2}/g).reverse().join('');
1324
+ const voutLE = inp.sourceOutputIndex.toString(16).padStart(8, '0')
1325
+ .match(/.{2}/g).reverse().join('');
1326
+ allPrevoutsHex += txidLE + voutLE;
1327
+ }
1328
+ for (const idx of prevoutsIndices) {
1329
+ inputArgs[idx] = allPrevoutsHex;
1330
+ }
1331
+ }
1332
+ let argsHex = '';
1333
+ for (const arg of inputArgs)
1334
+ argsHex += encodeArg(arg);
1335
+ let changeHex = '';
1336
+ if (methodNeedsChange && changePKHHex) {
1337
+ changeHex = encodePushData(changePKHHex) + encodeArg(BigInt(txChangeAmount ?? 0));
1338
+ }
1339
+ let newAmountHex = '';
1340
+ if (methodNeedsNewAmount) {
1341
+ newAmountHex = encodeArg(BigInt(newSatoshis ?? this.currentUtxo.satoshis));
1342
+ }
1343
+ const unlock = this.buildStatefulPrefix(opSig, methodUsesCodePart) + argsHex + changeHex + newAmountHex + encodePushData(preimage) + witnessHex + methodSelectorHex;
1344
+ return { unlock, opSig, preimage };
1345
+ };
1346
+ // First pass
1347
+ const { unlock: input0Unlock } = await buildStatefulUnlock(tx, 0, contractUtxo.script, contractUtxo.satoshis, undefined, changeAmount);
1348
+ const extraUnlocks = [];
1349
+ for (let i = 0; i < extraContractUtxos.length; i++) {
1350
+ const mu = extraContractUtxos[i];
1351
+ const extraArgs = perInputArgs?.[i] ? resolvedPerInputArgs?.[i] : undefined;
1352
+ const { unlock } = await buildStatefulUnlock(tx, i + 1, mu.script, mu.satoshis, extraArgs, changeAmount);
1353
+ extraUnlocks.push(unlock);
1354
+ }
1355
+ // Rebuild TX with real unlocking scripts.
1356
+ //
1357
+ // Deep-review finding C13: the funding coin-selection above ran against
1358
+ // an ESTIMATE (placeholder unlock + the `perContractInputOverhead`
1359
+ // heuristic), not this REAL unlock — and there is no re-selection pass
1360
+ // here if the real size turns out bigger than estimated. Investigated:
1361
+ // this IS reachable (e.g. a stateful method whose current — spent —
1362
+ // locking script is far larger than its new/continuation one, since the
1363
+ // heuristic only has the NEW script's length to go on; see
1364
+ // `c13-call-funding-fee-bound.test.ts`). It can NEVER produce an
1365
+ // invalid or overspending tx, though: `buildCallTransaction` always
1366
+ // computes fee/change from these REAL bytes (not the estimate), and
1367
+ // finding C3's fail-closed guard guarantees `totalInput >=
1368
+ // contractOutputSats`, so the worst case is a lower-than-intended fee
1369
+ // down to (and never below) 0 — the same zero-fee "exact cover" floor
1370
+ // issue #116 already established as valid/intentional. No re-selection
1371
+ // loop is added here; see the linked test for the invariant this bound
1372
+ // relies on.
1373
+ ({ tx, inputCount, changeAmount } = buildCallTransaction(this.currentUtxo, input0Unlock, newLockingScript, newSatoshis, changeAddress, changeScript, additionalUtxos.length > 0 ? additionalUtxos : undefined, feeRate, {
1374
+ contractOutputs,
1375
+ additionalContractInputs: extraContractUtxos.length > 0
1376
+ ? extraContractUtxos.map((utxo, i) => ({ utxo, unlockingScript: extraUnlocks[i] }))
1377
+ : undefined,
1378
+ dataOutputs: resolvedDataOutputs.length > 0 ? resolvedDataOutputs : undefined,
1379
+ // Rebuild path must honor the override too: a preimage computed on a
1380
+ // rebuilt tx with locktime 0 would mismatch the final on-chain tx.
1381
+ locktime: options?.locktime,
1382
+ // Same for sequence — the second-pass preimage must see the final
1383
+ // input sequences (issue #131).
1384
+ sequence: options?.sequence,
1385
+ }));
1386
+ // Second pass: recompute with final tx
1387
+ const { unlock: finalInput0Unlock, opSig, preimage } = await buildStatefulUnlock(tx, 0, contractUtxo.script, contractUtxo.satoshis, undefined, changeAmount);
1388
+ finalOpPushTxSig = opSig;
1389
+ finalPreimage = preimage;
1390
+ tx.inputs[0].unlockingScript = UnlockingScript.fromHex(finalInput0Unlock);
1391
+ invalidateTxCache(tx);
1392
+ for (let i = 0; i < extraContractUtxos.length; i++) {
1393
+ const mu = extraContractUtxos[i];
1394
+ const extraArgs = perInputArgs?.[i] ? resolvedPerInputArgs?.[i] : undefined;
1395
+ const { unlock: finalMergeUnlock } = await buildStatefulUnlock(tx, i + 1, mu.script, mu.satoshis, extraArgs, changeAmount);
1396
+ tx.inputs[i + 1].unlockingScript = UnlockingScript.fromHex(finalMergeUnlock);
1397
+ invalidateTxCache(tx);
1398
+ }
1399
+ // Re-sign P2PKH funding inputs (with fundingSigner — issue #134)
1400
+ txHex = tx.toHex();
1401
+ for (let i = p2pkhStartIdx; i < inputCount; i++) {
1402
+ const utxo = additionalUtxos[i - p2pkhStartIdx];
1403
+ if (utxo) {
1404
+ const sig = await fundingSigner.sign(txHex, i, utxo.script, utxo.satoshis);
1405
+ const pubKey = await fundingSigner.getPublicKey();
1406
+ const unlockScript = encodePushData(sig) + encodePushData(pubKey);
1407
+ tx.inputs[i].unlockingScript = UnlockingScript.fromHex(unlockScript);
1408
+ invalidateTxCache(tx);
1409
+ txHex = tx.toHex();
1410
+ }
1411
+ }
1412
+ // Update resolvedArgs with real prevouts so finalizeCall can
1413
+ // rebuild the primary unlock with correct allPrevouts values.
1414
+ if (prevoutsIndices.length > 0) {
1415
+ let allPrevoutsHex = '';
1416
+ for (const inp of tx.inputs) {
1417
+ const txidLE = inp.sourceTXID.match(/.{2}/g).reverse().join('');
1418
+ const voutLE = inp.sourceOutputIndex.toString(16).padStart(8, '0')
1419
+ .match(/.{2}/g).reverse().join('');
1420
+ allPrevoutsHex += txidLE + voutLE;
1421
+ }
1422
+ for (const idx of prevoutsIndices) {
1423
+ resolvedArgs[idx] = allPrevoutsHex;
1424
+ }
1425
+ }
1426
+ }
1427
+ else if (needsOpPushTx || sigIndices.length > 0) {
1428
+ // Stateless: keep placeholder sigs, compute OP_PUSH_TX
1429
+ if (needsOpPushTx) {
1430
+ const { sigHex, preimageHex } = this.computeOpPushTxWithCodeSep(tx, 0, contractUtxo.script, contractUtxo.satoshis, methodIndex);
1431
+ finalOpPushTxSig = sigHex;
1432
+ resolvedArgs[preimageIndex] = preimageHex;
1433
+ }
1434
+ // Don't sign Sig params — keep placeholders
1435
+ let realUnlockingScript = this.buildUnlockingScript(methodName, resolvedArgs);
1436
+ if (needsOpPushTx && finalOpPushTxSig) {
1437
+ realUnlockingScript = this.buildStatefulPrefix(finalOpPushTxSig) + realUnlockingScript;
1438
+ tx.inputs[0].unlockingScript = UnlockingScript.fromHex(realUnlockingScript);
1439
+ invalidateTxCache(tx);
1440
+ const { sigHex: finalSig, preimageHex: finalPre } = this.computeOpPushTxWithCodeSep(tx, 0, contractUtxo.script, contractUtxo.satoshis, methodIndex);
1441
+ resolvedArgs[preimageIndex] = finalPre;
1442
+ finalOpPushTxSig = finalSig;
1443
+ finalPreimage = finalPre;
1444
+ realUnlockingScript = this.buildStatefulPrefix(finalSig) +
1445
+ this.buildUnlockingScript(methodName, resolvedArgs);
1446
+ }
1447
+ tx.inputs[0].unlockingScript = UnlockingScript.fromHex(realUnlockingScript);
1448
+ invalidateTxCache(tx);
1449
+ if (!finalPreimage && needsOpPushTx) {
1450
+ finalPreimage = resolvedArgs[preimageIndex];
1451
+ }
1452
+ }
1453
+ // Compute sighash from preimage (C19: the true BIP-143 digest external
1454
+ // signers must ECDSA-sign — hash256(preimage), not sha256(preimage)).
1455
+ const sighash = finalPreimage ? computeBip143Sighash(finalPreimage) : '';
1456
+ return {
1457
+ sighash,
1458
+ preimage: finalPreimage,
1459
+ opPushTxSig: finalOpPushTxSig,
1460
+ tx,
1461
+ sigIndices,
1462
+ _methodName: methodName,
1463
+ _resolvedArgs: resolvedArgs,
1464
+ _methodSelectorHex: methodSelectorHex,
1465
+ _isStateful: isStateful,
1466
+ _parentStateful: parentStateful,
1467
+ _isTerminal: false,
1468
+ _needsOpPushTx: needsOpPushTx,
1469
+ _methodNeedsChange: methodNeedsChange,
1470
+ _methodUsesCodePart: methodUsesCodePart,
1471
+ _changePKHHex: changePKHHex,
1472
+ _changeAmount: changeAmount,
1473
+ _methodNeedsNewAmount: methodNeedsNewAmount,
1474
+ _newAmount: newSatoshis ?? this.currentUtxo.satoshis,
1475
+ _preimageIndex: preimageIndex,
1476
+ _contractUtxo: contractUtxo,
1477
+ _newLockingScript: newLockingScript ?? '',
1478
+ _newSatoshis: newSatoshis ?? 0,
1479
+ _hasMultiOutput: !!hasMultiOutput,
1480
+ _contractOutputs: contractOutputs ?? [],
1481
+ _intentWitnessHex: witnessHex,
1482
+ _dryRun: options?.dryRun ?? false,
1483
+ };
1484
+ }
1485
+ /**
1486
+ * Complete a prepared call by injecting external signatures and broadcasting.
1487
+ *
1488
+ * @param prepared — The `PreparedCall` returned by `prepareCall()`.
1489
+ * @param signatures — Map from arg index to DER signature hex (with sighash byte).
1490
+ * Each key must be one of `prepared.sigIndices`.
1491
+ */
1492
+ async finalizeCall(prepared, signatures) {
1493
+ // C29: one-shot guard. Mark consumed BEFORE any mutation/broadcast work
1494
+ // so a second call fails fast regardless of whether the first attempt
1495
+ // succeeded, was rejected by the C8 dry-run, or threw during broadcast —
1496
+ // `prepared.tx` may already have been mutated by that first attempt in
1497
+ // every one of those cases.
1498
+ if (consumedPreparedCalls.has(prepared)) {
1499
+ throw new Error('RunarContract.finalizeCall: this PreparedCall has already been finalized (deep-review C29). ' +
1500
+ 'A PreparedCall is one-shot — call prepareCall() again to build a fresh one.');
1501
+ }
1502
+ consumedPreparedCalls.add(prepared);
1503
+ const { provider } = this.resolveProviderSigner();
1504
+ // Replace placeholder sigs with real signatures
1505
+ const resolvedArgs = [...prepared._resolvedArgs];
1506
+ for (const idx of prepared.sigIndices) {
1507
+ if (signatures[idx] !== undefined) {
1508
+ resolvedArgs[idx] = signatures[idx];
1509
+ }
1510
+ }
1511
+ // Assemble the primary unlocking script
1512
+ let primaryUnlock;
1513
+ if (prepared._isStateful) {
1514
+ let argsHex = '';
1515
+ for (const arg of resolvedArgs)
1516
+ argsHex += encodeArg(arg);
1517
+ let changeHex = '';
1518
+ if (prepared._methodNeedsChange && prepared._changePKHHex) {
1519
+ changeHex = encodePushData(prepared._changePKHHex) +
1520
+ encodeArg(BigInt(prepared._changeAmount));
1521
+ }
1522
+ let newAmountHex = '';
1523
+ if (prepared._methodNeedsNewAmount) {
1524
+ newAmountHex = encodeArg(BigInt(prepared._newAmount));
1525
+ }
1526
+ primaryUnlock =
1527
+ this.buildStatefulPrefix(prepared.opPushTxSig, prepared._methodUsesCodePart ?? prepared._methodNeedsChange) +
1528
+ argsHex +
1529
+ changeHex +
1530
+ newAmountHex +
1531
+ encodePushData(prepared.preimage) +
1532
+ prepared._intentWitnessHex +
1533
+ prepared._methodSelectorHex;
1534
+ }
1535
+ else if (prepared._needsOpPushTx) {
1536
+ // Stateless with SigHashPreimage: put preimage into resolvedArgs
1537
+ if (prepared._preimageIndex >= 0) {
1538
+ resolvedArgs[prepared._preimageIndex] = prepared.preimage;
1539
+ }
1540
+ primaryUnlock = this.buildStatefulPrefix(prepared.opPushTxSig) +
1541
+ this.buildUnlockingScript(prepared._methodName, resolvedArgs);
1542
+ }
1543
+ else {
1544
+ primaryUnlock = this.buildUnlockingScript(prepared._methodName, resolvedArgs);
1545
+ }
1546
+ // Insert primary unlock into the transaction
1547
+ const finalTx = prepared.tx;
1548
+ finalTx.inputs[0].unlockingScript = UnlockingScript.fromHex(primaryUnlock);
1549
+ invalidateTxCache(finalTx);
1550
+ // C8: local pre-broadcast dry-run. Replay the fully-signed primary
1551
+ // contract input through @bsv/sdk's production Spend interpreter and fail
1552
+ // closed — instead of broadcasting a tx the network would reject — unless
1553
+ // the caller opted IN (CallOptions.dryRun, threaded via prepareCall).
1554
+ // Opt-in rather than default-on: see the CallOptions.dryRun docstring for
1555
+ // the false-rejection evidence that forced this polarity.
1556
+ if (prepared._dryRun) {
1557
+ const dryRun = dryRunContractInput(finalTx, 0, prepared._contractUtxo.script, prepared._contractUtxo.satoshis);
1558
+ if (!dryRun.valid) {
1559
+ throw new Error(`RunarContract.finalizeCall: local pre-broadcast dry-run rejected the primary ` +
1560
+ `contract input (deep-review C8)${dryRun.error ? `: ${dryRun.error}` : ' (script evaluated to false)'}. ` +
1561
+ `Refusing to broadcast a tx the network would reject. Omit { dryRun: true } in ` +
1562
+ `CallOptions to bypass (see PreparedCall docs).`);
1563
+ }
1564
+ }
1565
+ // Broadcast
1566
+ const txid = await provider.broadcast(finalTx);
1567
+ // Update tracked UTXO
1568
+ if (prepared._isStateful && prepared._hasMultiOutput && prepared._contractOutputs.length > 0) {
1569
+ this.currentUtxo = {
1570
+ txid,
1571
+ outputIndex: 0,
1572
+ satoshis: prepared._contractOutputs[0].satoshis,
1573
+ script: prepared._contractOutputs[0].script,
1574
+ };
1575
+ }
1576
+ else if (prepared._isStateful && prepared._newLockingScript) {
1577
+ // The state continuation is normally output 0, but a method that also
1578
+ // calls `this.addRawOutput(...)` (finding G1) can push raw outputs ahead
1579
+ // of it. `_contractOutputs`, when populated (the raw-output path),
1580
+ // records the real source order and each output's satoshis — so track the
1581
+ // continuation at its actual index and value (which may legitimately be
1582
+ // 0). Empty `_contractOutputs` (the common case) keeps the legacy
1583
+ // index-0 / `_newSatoshis`-fallback behaviour byte-for-byte unchanged.
1584
+ const contIdx = prepared._contractOutputs.length > 0
1585
+ ? prepared._contractOutputs.findIndex((o) => o.script === prepared._newLockingScript)
1586
+ : -1;
1587
+ this.currentUtxo = contIdx >= 0
1588
+ ? {
1589
+ txid,
1590
+ outputIndex: contIdx,
1591
+ satoshis: prepared._contractOutputs[contIdx].satoshis,
1592
+ script: prepared._newLockingScript,
1593
+ }
1594
+ : {
1595
+ txid,
1596
+ outputIndex: 0,
1597
+ satoshis: prepared._newSatoshis || prepared._contractUtxo.satoshis,
1598
+ script: prepared._newLockingScript,
1599
+ };
1600
+ }
1601
+ else if (prepared._isTerminal) {
1602
+ this.currentUtxo = null;
1603
+ }
1604
+ else {
1605
+ this.currentUtxo = null;
1606
+ }
1607
+ const txData = await provider.getTransaction(txid).catch((err) => {
1608
+ // Audit finding C4 — see the peer fallback in `deploy()`.
1609
+ console.warn('Failed to fetch transaction after broadcast:', err);
1610
+ return txToTransactionData(txid, finalTx);
1611
+ });
1612
+ return { txid, tx: txData };
1613
+ }
1614
+ // -------------------------------------------------------------------------
1615
+ // State access
1616
+ // -------------------------------------------------------------------------
1617
+ /** Get the current contract state (for stateful contracts). */
1618
+ get state() {
1619
+ return { ...this._state };
1620
+ }
1621
+ /** Update state values directly (for stateful contracts). */
1622
+ setState(newState) {
1623
+ this._state = { ...this._state, ...newState };
1624
+ }
1625
+ // -------------------------------------------------------------------------
1626
+ // Intent-intrinsic witness values
1627
+ // -------------------------------------------------------------------------
1628
+ /**
1629
+ * Supply the prev-output locking-script witness for input `inputIndex`.
1630
+ * Required for methods that call `extractPrevOutputScript(inputIndex)`,
1631
+ * which the compiler lowers into an auto-injected
1632
+ * `_prevOutScript_<inputIndex>` ABI param.
1633
+ *
1634
+ * @param inputIndex the literal input index passed to extractPrevOutputScript
1635
+ * @param bytes hex string (with or without 0x prefix) or raw bytes
1636
+ */
1637
+ setPrevOutScript(inputIndex, bytes) {
1638
+ this._prevOutScripts.set(inputIndex, normalizeWitnessBytes(bytes));
1639
+ }
1640
+ /**
1641
+ * Supply the serialised-outputs witness for the current call.
1642
+ * Required for methods that call `requireOutputP2PKH(...)`, which the
1643
+ * compiler lowers into an auto-injected `_serialisedOutputs` ABI param.
1644
+ *
1645
+ * @param bytes hex string (with or without 0x prefix) or raw bytes
1646
+ */
1647
+ setSerialisedOutputs(bytes) {
1648
+ this._serialisedOutputs = normalizeWitnessBytes(bytes);
1649
+ }
1650
+ /**
1651
+ * Build the trailing witness-hex for the auto-injected intent-intrinsic
1652
+ * params of a method, in ABI order (`_prevOutScript_*` first, then
1653
+ * `_serialisedOutputs`). Each value is pushed via PUSHDATA so that the
1654
+ * on-chain method body's `load_param` lifts the exact bytes the caller set.
1655
+ *
1656
+ * Throws {@link WitnessValueMissingError} for any auto-injected param the
1657
+ * caller hasn't supplied via setPrevOutScript / setSerialisedOutputs.
1658
+ */
1659
+ buildIntentWitnessHex(method) {
1660
+ let hex = '';
1661
+ for (const p of method.params) {
1662
+ if (p.name.startsWith('_prevOutScript_')) {
1663
+ const idxStr = p.name.slice('_prevOutScript_'.length);
1664
+ const idx = Number(idxStr);
1665
+ const val = this._prevOutScripts.get(idx);
1666
+ if (val === undefined) {
1667
+ throw new WitnessValueMissingError({
1668
+ paramName: p.name,
1669
+ methodName: method.name,
1670
+ contractName: this.artifact.contractName,
1671
+ });
1672
+ }
1673
+ hex += encodePushData(val);
1674
+ }
1675
+ else if (p.name === '_serialisedOutputs') {
1676
+ if (this._serialisedOutputs === null) {
1677
+ throw new WitnessValueMissingError({
1678
+ paramName: p.name,
1679
+ methodName: method.name,
1680
+ contractName: this.artifact.contractName,
1681
+ });
1682
+ }
1683
+ hex += encodePushData(this._serialisedOutputs);
1684
+ }
1685
+ }
1686
+ return hex;
1687
+ }
1688
+ // -------------------------------------------------------------------------
1689
+ // Script construction
1690
+ // -------------------------------------------------------------------------
1691
+ /**
1692
+ * Get the full locking script hex for the contract.
1693
+ *
1694
+ * For stateful contracts this includes the code followed by OP_RETURN and
1695
+ * the serialized state fields.
1696
+ */
1697
+ getLockingScript() {
1698
+ // Use stored code script from chain if available (reconnected contract).
1699
+ // When loaded from chain, _codeScript already contains the inscription
1700
+ // envelope (if any). When built from the template, we splice it in.
1701
+ const builtFromTemplate = this._codeScript === null;
1702
+ let script = this._codeScript ?? this.buildCodeScript();
1703
+ // Inject inscription envelope between code and state (template-built only;
1704
+ // chain-loaded _codeScript already includes it).
1705
+ if (builtFromTemplate && this._inscription) {
1706
+ script += buildInscriptionEnvelope(this._inscription.contentType, this._inscription.data);
1707
+ }
1708
+ // Append state section for stateful contracts
1709
+ if (this.artifact.stateFields && this.artifact.stateFields.length > 0) {
1710
+ const stateHex = serializeState(this.artifact.stateFields, this._state);
1711
+ if (stateHex.length > 0) {
1712
+ script += '6a'; // OP_RETURN
1713
+ script += stateHex;
1714
+ }
1715
+ }
1716
+ return script;
1717
+ }
1718
+ /**
1719
+ * Build the code portion of the locking script from the artifact and
1720
+ * constructor args. This is the script without any state suffix.
1721
+ */
1722
+ buildCodeScript() {
1723
+ let script = this.artifact.script;
1724
+ const hasConstructorSlots = this.artifact.constructorSlots && this.artifact.constructorSlots.length > 0;
1725
+ const hasCodeSepSlots = this.artifact.codeSepIndexSlots && this.artifact.codeSepIndexSlots.length > 0;
1726
+ if (hasConstructorSlots || hasCodeSepSlots) {
1727
+ const subs = [];
1728
+ // Constructor arg slots: replace OP_0 placeholder with encoded arg
1729
+ if (hasConstructorSlots) {
1730
+ for (const slot of this.artifact.constructorSlots) {
1731
+ subs.push({
1732
+ byteOffset: slot.byteOffset,
1733
+ encoded: encodeArg(this.constructorArgs[slot.paramIndex]),
1734
+ });
1735
+ }
1736
+ }
1737
+ // CodeSepIndex slots: replace OP_0 placeholder with encoded adjusted
1738
+ // codeSeparatorIndex. The adjusted value accounts for constructor arg
1739
+ // expansion AND earlier codeSepIndex slot expansions that shift
1740
+ // OP_CODESEPARATOR positions in the substituted script.
1741
+ if (hasCodeSepSlots) {
1742
+ const resolved = this._resolvedCodeSepSlotValues();
1743
+ for (const rs of resolved) {
1744
+ subs.push({
1745
+ byteOffset: rs.templateByteOffset,
1746
+ encoded: encodeScriptNumber(BigInt(rs.adjustedValue)),
1747
+ });
1748
+ }
1749
+ }
1750
+ // Sort descending by byte offset and apply
1751
+ subs.sort((a, b) => b.byteOffset - a.byteOffset);
1752
+ for (const sub of subs) {
1753
+ const hexOffset = sub.byteOffset * 2;
1754
+ // Replace the 1-byte OP_0 placeholder (2 hex chars) with the encoded value
1755
+ script = script.slice(0, hexOffset) + sub.encoded + script.slice(hexOffset + 2);
1756
+ }
1757
+ }
1758
+ else if (!this.artifact.stateFields || this.artifact.stateFields.length === 0) {
1759
+ // Backward compatibility: old stateless artifacts without constructorSlots.
1760
+ // For stateful contracts, constructor args initialize the state section
1761
+ // (after OP_RETURN), not the code portion.
1762
+ for (const arg of this.constructorArgs) {
1763
+ script += encodeArg(arg);
1764
+ }
1765
+ }
1766
+ return script;
1767
+ }
1768
+ /**
1769
+ * Build the unlocking script for a method call.
1770
+ *
1771
+ * The unlocking script pushes the method arguments onto the stack in
1772
+ * order, followed by a method selector (the method index as a Script
1773
+ * number) if the contract has multiple public methods.
1774
+ */
1775
+ buildUnlockingScript(methodName, args) {
1776
+ let script = '';
1777
+ // Push each argument
1778
+ for (const arg of args) {
1779
+ script += encodeArg(arg);
1780
+ }
1781
+ // If there are multiple public methods, push the method selector
1782
+ const publicMethods = this.artifact.abi.methods.filter((m) => m.isPublic);
1783
+ if (publicMethods.length > 1) {
1784
+ const methodIndex = publicMethods.findIndex((m) => m.name === methodName);
1785
+ if (methodIndex < 0) {
1786
+ throw new Error(`buildUnlockingScript: public method '${methodName}' not found`);
1787
+ }
1788
+ script += encodeScriptNumber(BigInt(methodIndex));
1789
+ }
1790
+ return script;
1791
+ }
1792
+ /**
1793
+ * Get the code script hex (locking script without state) for use as _codePart.
1794
+ * Returns the code portion that the on-chain contract uses for output reconstruction.
1795
+ * Includes the inscription envelope if one is attached — this is required for
1796
+ * stateful contracts where the on-chain hashOutputs verification includes
1797
+ * the envelope as part of the codePart.
1798
+ *
1799
+ * Public low-level assembly surface: needed for cross-artifact transaction
1800
+ * assembly (see `assembleMultiContractCall`), where a spending tx mixes
1801
+ * covenant inputs from DIFFERENT artifacts and each input's `_codePart` /
1802
+ * continuation script must be built outside `call()`.
1803
+ */
1804
+ getCodePartHex() {
1805
+ if (this._codeScript)
1806
+ return this._codeScript;
1807
+ let code = this.buildCodeScript();
1808
+ if (this._inscription) {
1809
+ code += buildInscriptionEnvelope(this._inscription.contentType, this._inscription.data);
1810
+ }
1811
+ return code;
1812
+ }
1813
+ /**
1814
+ * Adjust a code separator byte offset from the base (template) script to
1815
+ * the fully-substituted script. Both constructor arg slots and codeSepIndex
1816
+ * slots replace OP_0 (1 byte) with encoded push data, shifting subsequent
1817
+ * byte offsets.
1818
+ */
1819
+ adjustCodeSepOffset(baseOffset) {
1820
+ let shift = 0;
1821
+ if (this.artifact.constructorSlots) {
1822
+ for (const slot of this.artifact.constructorSlots) {
1823
+ if (slot.byteOffset < baseOffset) {
1824
+ const encoded = encodeArg(this.constructorArgs[slot.paramIndex]);
1825
+ shift += encoded.length / 2 - 1; // encoded bytes minus the 1-byte OP_0 placeholder
1826
+ }
1827
+ }
1828
+ }
1829
+ // Account for codeSepIndex slot expansions. Each slot's encoded value
1830
+ // is the fully-adjusted codeSep index, computed by resolveCodeSepSlotValues.
1831
+ const resolvedSlots = this._resolvedCodeSepSlotValues();
1832
+ for (const rs of resolvedSlots) {
1833
+ if (rs.templateByteOffset < baseOffset) {
1834
+ const encoded = encodeScriptNumber(BigInt(rs.adjustedValue));
1835
+ shift += encoded.length / 2 - 1;
1836
+ }
1837
+ }
1838
+ return baseOffset + shift;
1839
+ }
1840
+ /**
1841
+ * Resolve the adjusted codeSep index values for all codeSepIndex slots,
1842
+ * processing them in ascending template byte-offset order so that each
1843
+ * slot's value correctly accounts for earlier slots' expansions.
1844
+ */
1845
+ _resolvedCodeSepSlotValues() {
1846
+ if (!this.artifact.codeSepIndexSlots || this.artifact.codeSepIndexSlots.length === 0) {
1847
+ return [];
1848
+ }
1849
+ // Sort by template byte offset ascending (left-to-right in the script)
1850
+ const sorted = [...this.artifact.codeSepIndexSlots].sort((a, b) => a.byteOffset - b.byteOffset);
1851
+ const result = [];
1852
+ for (const slot of sorted) {
1853
+ // Compute the fully-adjusted codeSep index: constructor expansion +
1854
+ // expansion from earlier codeSepIndex slots that precede this slot's codeSepIndex.
1855
+ let shift = 0;
1856
+ if (this.artifact.constructorSlots) {
1857
+ for (const cs of this.artifact.constructorSlots) {
1858
+ if (cs.byteOffset < slot.codeSepIndex) {
1859
+ const encoded = encodeArg(this.constructorArgs[cs.paramIndex]);
1860
+ shift += encoded.length / 2 - 1;
1861
+ }
1862
+ }
1863
+ }
1864
+ for (const prev of result) {
1865
+ if (prev.templateByteOffset < slot.codeSepIndex) {
1866
+ const prevEncoded = encodeScriptNumber(BigInt(prev.adjustedValue));
1867
+ shift += prevEncoded.length / 2 - 1;
1868
+ }
1869
+ }
1870
+ result.push({ templateByteOffset: slot.byteOffset, adjustedValue: slot.codeSepIndex + shift });
1871
+ }
1872
+ return result;
1873
+ }
1874
+ /**
1875
+ * Get the subscript trimmed at the OP_CODESEPARATOR for a given method.
1876
+ * Used for BIP-143 sighash computation for the user CHECKSIG whenever the
1877
+ * script contains an OP_CODESEPARATOR before it (per BIP-143 / BSV consensus,
1878
+ * for stateful AND terminal methods alike). Returns the full script unchanged
1879
+ * when no OP_CODESEPARATOR is present.
1880
+ *
1881
+ * When `_codeScript` is set (the contract is loaded from chain, or the deploy
1882
+ * script has already been built from real constructor args), walk the actual
1883
+ * script and trim at the true on-chain byte position. This is required
1884
+ * because `fromTxid` populates constructorArgs with dummy placeholders — the
1885
+ * real arg bytes are already baked into the on-chain locking script — so
1886
+ * `adjustCodeSepOffset` computes a shift of zero and returns the wrong offset
1887
+ * whenever the OP_CODESEPARATOR sits after constructor slots that expand at
1888
+ * deploy time. The symptom of using the wrong offset is NULLFAIL at
1889
+ * OP_CHECKSIG for terminal methods.
1890
+ *
1891
+ * Public low-level assembly surface: multi-contract tx assembly must sign
1892
+ * each covenant input's user `Sig` over ITS artifact's codesep-trimmed
1893
+ * subscript, which `call()` only does for its own single primary input.
1894
+ */
1895
+ getSubscriptForSigning(fullScript, methodIndex) {
1896
+ if (this._codeScript !== null) {
1897
+ const realOffsets = findCodesepOffsets(this._codeScript);
1898
+ if (realOffsets.length > 0) {
1899
+ const indices = this.artifact.codeSeparatorIndices;
1900
+ let off;
1901
+ if (indices && methodIndex !== undefined && methodIndex < indices.length
1902
+ && methodIndex < realOffsets.length) {
1903
+ off = realOffsets[methodIndex];
1904
+ }
1905
+ else if (this.artifact.codeSeparatorIndex !== undefined) {
1906
+ off = realOffsets[0];
1907
+ }
1908
+ if (off !== undefined) {
1909
+ return fullScript.slice((off + 1) * 2);
1910
+ }
1911
+ }
1912
+ }
1913
+ const indices = this.artifact.codeSeparatorIndices;
1914
+ let codeSepIdx;
1915
+ if (indices && methodIndex !== undefined && methodIndex < indices.length) {
1916
+ codeSepIdx = indices[methodIndex];
1917
+ }
1918
+ else {
1919
+ codeSepIdx = this.artifact.codeSeparatorIndex;
1920
+ }
1921
+ if (codeSepIdx !== undefined) {
1922
+ codeSepIdx = this.adjustCodeSepOffset(codeSepIdx);
1923
+ // Skip past the separator byte (+1 byte = +2 hex chars)
1924
+ return fullScript.slice((codeSepIdx + 1) * 2);
1925
+ }
1926
+ return fullScript;
1927
+ }
1928
+ /**
1929
+ * Wrap computeOpPushTx to automatically pass the correct codeSeparatorIndex.
1930
+ * For multi-method contracts, each method has its own separator at a different
1931
+ * byte offset. Uses codeSeparatorIndices[methodIndex] if available, otherwise
1932
+ * falls back to the single codeSeparatorIndex.
1933
+ *
1934
+ * When `_codeScript` is set (chain-loaded, or a deploy script already built
1935
+ * from real constructor args), byte-walk the actual script for the true
1936
+ * on-chain OP_CODESEPARATOR offset — mirroring `getSubscriptForSigning`.
1937
+ * `adjustCodeSepOffset` derives the shift from the in-memory
1938
+ * `constructorArgs`, which are placeholders on the restore path (issue #132);
1939
+ * the byte-walk is independent of those args. The template `adjustCodeSepOffset`
1940
+ * path remains only for template-built (deploy-time) contracts.
1941
+ *
1942
+ * Public low-level assembly surface: each covenant input of a
1943
+ * multi-contract tx needs its own OP_PUSH_TX signature + BIP-143 preimage
1944
+ * computed against ITS artifact's code separator layout.
1945
+ */
1946
+ computeOpPushTxWithCodeSep(tx, inputIndex, subscript, satoshis, methodIndex) {
1947
+ let codeSepIdx;
1948
+ if (this._codeScript !== null) {
1949
+ const realOffsets = findCodesepOffsets(this._codeScript);
1950
+ if (realOffsets.length > 0) {
1951
+ const indices = this.artifact.codeSeparatorIndices;
1952
+ if (indices && methodIndex !== undefined && methodIndex < indices.length
1953
+ && methodIndex < realOffsets.length) {
1954
+ codeSepIdx = realOffsets[methodIndex];
1955
+ }
1956
+ else if (this.artifact.codeSeparatorIndex !== undefined) {
1957
+ codeSepIdx = realOffsets[0];
1958
+ }
1959
+ }
1960
+ }
1961
+ if (codeSepIdx === undefined) {
1962
+ // Template (deploy-time) path: derive the shifted offset from the args.
1963
+ codeSepIdx = this.artifact.codeSeparatorIndex;
1964
+ const indices = this.artifact.codeSeparatorIndices;
1965
+ if (indices && methodIndex !== undefined && methodIndex < indices.length) {
1966
+ codeSepIdx = indices[methodIndex];
1967
+ }
1968
+ if (codeSepIdx !== undefined) {
1969
+ codeSepIdx = this.adjustCodeSepOffset(codeSepIdx);
1970
+ }
1971
+ }
1972
+ // Issue #123: build the preimage under the method's declared @sighash mode.
1973
+ // `methodIndex` indexes the public-methods list (same convention as the
1974
+ // codeSeparatorIndices lookup above); a method with no directive carries no
1975
+ // `sigHashType` and falls back to 0x41 (ALL|FORKID), unchanged behaviour.
1976
+ const publicMethods = this.artifact.abi.methods.filter((m) => m.isPublic);
1977
+ const sigHashType = (methodIndex !== undefined ? publicMethods[methodIndex]?.sigHashType : undefined) ?? 0x41;
1978
+ return computeOpPushTx(tx, inputIndex, subscript, satoshis, codeSepIdx, sigHashType);
1979
+ }
1980
+ /**
1981
+ * Build the prefix for an unlocking script: optionally _codePart.
1982
+ * needsCodePart should be true only when the method constructs continuation outputs
1983
+ * (non-terminal stateful calls). Terminal and stateless methods don't use _codePart.
1984
+ *
1985
+ * Public low-level assembly surface. BUG-100: the OP_PUSH_TX signature is
1986
+ * derived on-chain from the preimage (see codegen emitCheckPreimageBinding),
1987
+ * so NO signature is pushed here — the stateful unlock layout is
1988
+ * `[_codePart?] args... [_changePKH _changeAmount] [_newAmount] txPreimage
1989
+ * [selector]` (multi-contract assembly rebuilds it per input). `opSig` is
1990
+ * accepted for call-site compatibility but ignored.
1991
+ */
1992
+ buildStatefulPrefix(_opSig, needsCodePart = false) {
1993
+ let prefix = '';
1994
+ if (needsCodePart && this.artifact.codeSeparatorIndex !== undefined) {
1995
+ prefix += encodePushData(this.getCodePartHex());
1996
+ }
1997
+ return prefix;
1998
+ }
1999
+ // -------------------------------------------------------------------------
2000
+ // Reconnection
2001
+ // -------------------------------------------------------------------------
2002
+ /**
2003
+ * Reconnect to an existing deployed contract from a known UTXO.
2004
+ *
2005
+ * This is the synchronous equivalent of `fromTxId()` — use it when the
2006
+ * UTXO data is already available (e.g. from an overlay service or cache)
2007
+ * without needing a Provider to fetch the transaction.
2008
+ *
2009
+ * @param artifact - The compiled artifact describing the contract.
2010
+ * @param utxo - The UTXO containing the contract output.
2011
+ * @returns A RunarContract instance connected to the existing UTXO.
2012
+ */
2013
+ static fromUtxo(artifact, utxo) {
2014
+ const contract = new RunarContract(artifact, restoreConstructorArgs(artifact, utxo.script));
2015
+ if (artifact.stateFields && artifact.stateFields.length > 0) {
2016
+ const lastOpReturn = findLastOpReturn(utxo.script);
2017
+ contract._codeScript = lastOpReturn !== -1
2018
+ ? utxo.script.slice(0, lastOpReturn)
2019
+ : utxo.script;
2020
+ }
2021
+ else {
2022
+ contract._codeScript = utxo.script;
2023
+ }
2024
+ // Detect inscription envelope in the code portion. Keep it in _codeScript
2025
+ // (do NOT strip) so that stateful continuation outputs preserve it.
2026
+ if (contract._codeScript) {
2027
+ const inscription = parseInscriptionEnvelope(contract._codeScript);
2028
+ if (inscription) {
2029
+ contract._inscription = inscription;
2030
+ }
2031
+ }
2032
+ contract.currentUtxo = {
2033
+ txid: utxo.txid,
2034
+ outputIndex: utxo.outputIndex,
2035
+ satoshis: utxo.satoshis,
2036
+ script: utxo.script,
2037
+ };
2038
+ if (artifact.stateFields && artifact.stateFields.length > 0) {
2039
+ const state = extractStateFromScript(artifact, utxo.script);
2040
+ // Deep-review finding C10: `extractStateFromScript` returns null ONLY
2041
+ // when the script has no recognisable state section at all (no
2042
+ // OP_RETURN found) — i.e. state extraction failed ENTIRELY, distinct
2043
+ // from "this one field has no on-chain slot" (issue #119's deliberate
2044
+ // zero-fill of unslotted CONSTRUCTOR args in `restoreConstructorArgs`,
2045
+ // untouched here). Silently keeping the constructor-initial `_state`
2046
+ // set by the `RunarContract` constructor above would present stale
2047
+ // deploy-time defaults as if they were live on-chain state. Fail
2048
+ // loudly instead of fabricating plausible-looking state.
2049
+ if (state === null) {
2050
+ throw new Error(`RunarContract.fromUtxo: could not extract state for ${artifact.contractName} — ` +
2051
+ `no state section (OP_RETURN) found in the UTXO script, but the artifact declares ` +
2052
+ `${artifact.stateFields.length} state field(s). Refusing to silently present ` +
2053
+ `constructor-initial state as live on-chain state.`);
2054
+ }
2055
+ contract._state = state;
2056
+ }
2057
+ return contract;
2058
+ }
2059
+ /**
2060
+ * Reconnect to an existing deployed contract from its deployment transaction.
2061
+ *
2062
+ * @param artifact - The compiled artifact describing the contract.
2063
+ * @param txid - The transaction ID containing the contract UTXO.
2064
+ * @param outputIndex - The output index of the contract UTXO.
2065
+ * @param provider - Blockchain provider.
2066
+ * @returns A RunarContract instance connected to the existing UTXO.
2067
+ */
2068
+ static async fromTxId(artifact, txid, outputIndex, provider) {
2069
+ const tx = await provider.getTransaction(txid);
2070
+ if (outputIndex >= tx.outputs.length) {
2071
+ throw new Error(`RunarContract.fromTxId: output index ${outputIndex} out of range (tx has ${tx.outputs.length} outputs)`);
2072
+ }
2073
+ const output = tx.outputs[outputIndex];
2074
+ return RunarContract.fromUtxo(artifact, {
2075
+ txid,
2076
+ outputIndex,
2077
+ satoshis: output.satoshis,
2078
+ script: output.script,
2079
+ });
2080
+ }
2081
+ // -------------------------------------------------------------------------
2082
+ // Private helpers
2083
+ // -------------------------------------------------------------------------
2084
+ findMethod(name) {
2085
+ return this.artifact.abi.methods.find((m) => m.name === name && m.isPublic);
2086
+ }
2087
+ }
2088
+ // ---------------------------------------------------------------------------
2089
+ // JSON BigInt revival
2090
+ // ---------------------------------------------------------------------------
2091
+ /**
2092
+ * Revive a value that may have been serialized as a BigInt string ("0n")
2093
+ * when the artifact JSON was loaded without the bigintReviver (e.g. via
2094
+ * Vite's `import artifact from './artifact.json'`).
2095
+ */
2096
+ function reviveJsonValue(value, type) {
2097
+ if (typeof value === 'string' && (type === 'bigint' || type === 'int')) {
2098
+ if (value.endsWith('n'))
2099
+ return BigInt(value.slice(0, -1));
2100
+ return BigInt(value);
2101
+ }
2102
+ return value;
2103
+ }
2104
+ /**
2105
+ * Recursively revive a (possibly nested) initialValue tree against its
2106
+ * declared type. For `FixedArray<FixedArray<bigint, 2>, 2>` this walks
2107
+ * 2 levels deep and reviveJsonValues each leaf as `bigint`.
2108
+ */
2109
+ function reviveNestedValue(value, type) {
2110
+ if (!type.startsWith('FixedArray<')) {
2111
+ return reviveJsonValue(value, type);
2112
+ }
2113
+ // Peel one FixedArray<inner, N> layer.
2114
+ const inner = type.slice('FixedArray<'.length, -1);
2115
+ let depth = 0;
2116
+ let splitAt = -1;
2117
+ for (let i = inner.length - 1; i >= 0; i--) {
2118
+ const ch = inner[i];
2119
+ if (ch === '>')
2120
+ depth++;
2121
+ else if (ch === '<')
2122
+ depth--;
2123
+ else if (ch === ',' && depth === 0) {
2124
+ splitAt = i;
2125
+ break;
2126
+ }
2127
+ }
2128
+ if (splitAt < 0)
2129
+ return value;
2130
+ const elemType = inner.slice(0, splitAt).trim();
2131
+ if (!Array.isArray(value))
2132
+ return value;
2133
+ return value.map(v => reviveNestedValue(v, elemType));
2134
+ }
2135
+ /**
2136
+ * Parse a nested `FixedArray<...>` type string into its outer dimensions,
2137
+ * returning `[outerLen, innerLen, ...]`. For example:
2138
+ * "FixedArray<bigint, 9>" -> [9]
2139
+ * "FixedArray<FixedArray<bigint, 2>, 2>" -> [2, 2]
2140
+ * "FixedArray<FixedArray<FixedArray<bigint,2>,3>,4>" -> [4, 3, 2]
2141
+ * A non-FixedArray type returns `[]`.
2142
+ */
2143
+ function parseFixedArrayDims(type) {
2144
+ const dims = [];
2145
+ let current = type.trim();
2146
+ while (current.startsWith('FixedArray<')) {
2147
+ const inner = current.slice('FixedArray<'.length, -1);
2148
+ // Find the matching comma that separates the element type from
2149
+ // the length — this is the last top-level comma, since the length
2150
+ // is always a bare integer and the element type may contain its
2151
+ // own `FixedArray<T, N>` commas.
2152
+ let depth = 0;
2153
+ let splitAt = -1;
2154
+ for (let i = inner.length - 1; i >= 0; i--) {
2155
+ const ch = inner[i];
2156
+ if (ch === '>')
2157
+ depth++;
2158
+ else if (ch === '<')
2159
+ depth--;
2160
+ else if (ch === ',' && depth === 0) {
2161
+ splitAt = i;
2162
+ break;
2163
+ }
2164
+ }
2165
+ if (splitAt < 0)
2166
+ return dims; // malformed
2167
+ const elemType = inner.slice(0, splitAt).trim();
2168
+ const lenStr = inner.slice(splitAt + 1).trim();
2169
+ const len = Number.parseInt(lenStr, 10);
2170
+ if (!Number.isFinite(len) || len <= 0)
2171
+ return dims;
2172
+ dims.push(len);
2173
+ current = elemType;
2174
+ }
2175
+ return dims;
2176
+ }
2177
+ /**
2178
+ * Recursively flatten a nested JS array of depth `dims.length` into a
2179
+ * flat list of leaf values in declaration order.
2180
+ */
2181
+ function flattenNested(value, dims) {
2182
+ if (dims.length === 0)
2183
+ return [value];
2184
+ const out = [];
2185
+ if (!Array.isArray(value)) {
2186
+ // Missing or wrong shape — emit `dims.reduce(*,1)` undefineds so
2187
+ // the caller can still reach the leaves.
2188
+ const total = dims.reduce((a, b) => a * b, 1);
2189
+ for (let i = 0; i < total; i++)
2190
+ out.push(undefined);
2191
+ return out;
2192
+ }
2193
+ const [, ...rest] = dims;
2194
+ for (const v of value) {
2195
+ out.push(...flattenNested(v, rest));
2196
+ }
2197
+ return out;
2198
+ }
2199
+ /**
2200
+ * Recursively rebuild a nested JS array of depth `dims.length` from a
2201
+ * flat list of leaf values in declaration order.
2202
+ */
2203
+ function regroupNested(flat, dims, offset = 0) {
2204
+ const [outerLen, ...rest] = dims;
2205
+ if (outerLen === undefined) {
2206
+ return { value: [], consumed: 0 };
2207
+ }
2208
+ const value = new Array(outerLen);
2209
+ let consumed = 0;
2210
+ if (rest.length === 0) {
2211
+ for (let i = 0; i < outerLen; i++) {
2212
+ value[i] = flat[offset + i];
2213
+ }
2214
+ consumed = outerLen;
2215
+ }
2216
+ else {
2217
+ for (let i = 0; i < outerLen; i++) {
2218
+ const sub = regroupNested(flat, rest, offset + consumed);
2219
+ value[i] = sub.value;
2220
+ consumed += sub.consumed;
2221
+ }
2222
+ }
2223
+ return { value, consumed };
2224
+ }
2225
+ /**
2226
+ * Flatten a state record whose grouped FixedArray entries (`Board`) hold
2227
+ * a (possibly nested) JS array of length N into a new record where each
2228
+ * leaf element is keyed by its underlying synthetic scalar name
2229
+ * (`Board__0`..`Board__8`, `Grid__0__0`..`Grid__1__1`, etc.). The
2230
+ * grouped entries are also preserved for callers that read them later.
2231
+ *
2232
+ * Used at the ANF-interpreter boundary, which knows only the expanded
2233
+ * scalar property names.
2234
+ */
2235
+ function flattenFixedArrayState(state, stateFields) {
2236
+ const out = { ...state };
2237
+ if (!stateFields)
2238
+ return out;
2239
+ for (const field of stateFields) {
2240
+ if (!field.fixedArray)
2241
+ continue;
2242
+ const value = state[field.name];
2243
+ if (!Array.isArray(value))
2244
+ continue;
2245
+ const dims = parseFixedArrayDims(field.type);
2246
+ const flat = flattenNested(value, dims);
2247
+ const syntheticNames = field.fixedArray.syntheticNames;
2248
+ for (let i = 0; i < syntheticNames.length; i++) {
2249
+ const synth = syntheticNames[i];
2250
+ // Do not overwrite an explicit scalar in the original state.
2251
+ if (!(synth in out))
2252
+ out[synth] = flat[i];
2253
+ }
2254
+ }
2255
+ return out;
2256
+ }
2257
+ /**
2258
+ * Re-group a state record's synthetic scalar entries back into arrays
2259
+ * (possibly nested) under their grouped names. Non-synthetic scalars
2260
+ * are passed through.
2261
+ */
2262
+ function regroupFixedArrayState(state, stateFields) {
2263
+ const out = { ...state };
2264
+ if (!stateFields)
2265
+ return out;
2266
+ for (const field of stateFields) {
2267
+ if (!field.fixedArray)
2268
+ continue;
2269
+ const syntheticNames = field.fixedArray.syntheticNames;
2270
+ const flat = new Array(syntheticNames.length);
2271
+ let sawAny = false;
2272
+ for (let i = 0; i < syntheticNames.length; i++) {
2273
+ const synth = syntheticNames[i];
2274
+ if (synth in out) {
2275
+ flat[i] = out[synth];
2276
+ sawAny = true;
2277
+ }
2278
+ }
2279
+ if (!sawAny)
2280
+ continue;
2281
+ // Fall back to the prior grouped value for still-missing leaves
2282
+ // by re-flattening it alongside the scalar updates.
2283
+ const prior = state[field.name];
2284
+ const dims = parseFixedArrayDims(field.type);
2285
+ if (Array.isArray(prior)) {
2286
+ const priorFlat = flattenNested(prior, dims);
2287
+ for (let i = 0; i < flat.length; i++) {
2288
+ if (flat[i] === undefined)
2289
+ flat[i] = priorFlat[i];
2290
+ }
2291
+ }
2292
+ const rebuilt = regroupNested(flat, dims);
2293
+ out[field.name] = rebuilt.value;
2294
+ }
2295
+ return out;
2296
+ }
2297
+ /**
2298
+ * If a constructor arg list uses the grouped FixedArray form
2299
+ * (`[someArray, ...]`), expand each (possibly nested) array-valued arg
2300
+ * into consecutive positional slots so the ANF interpreter's index-based
2301
+ * lookup works.
2302
+ */
2303
+ function flattenFixedArrayArgs(args, abiParams) {
2304
+ const out = [];
2305
+ for (let i = 0; i < args.length; i++) {
2306
+ const param = abiParams[i];
2307
+ const value = args[i];
2308
+ if (param?.fixedArray && Array.isArray(value)) {
2309
+ const dims = parseFixedArrayDims(param.type);
2310
+ if (dims.length > 0) {
2311
+ out.push(...flattenNested(value, dims));
2312
+ }
2313
+ else {
2314
+ for (const v of value)
2315
+ out.push(v);
2316
+ }
2317
+ }
2318
+ else {
2319
+ out.push(value);
2320
+ }
2321
+ }
2322
+ return out;
2323
+ }
2324
+ /**
2325
+ * Recover the positional constructor argument list from a deployed locking
2326
+ * script, so a restored contract (`fromUtxo` / `fromTxId`) operates on the real
2327
+ * baked-in values rather than `0n` placeholders.
2328
+ *
2329
+ * `extractConstructorArgs` returns a name→value map keyed by ABI param name;
2330
+ * `abi.constructor.params` is already ordered by paramIndex, so mapping over it
2331
+ * yields the positional `unknown[]` the `RunarContract` constructor expects.
2332
+ *
2333
+ * Params that carry no constructor slot (mutable state fields — their value
2334
+ * lives in the OP_RETURN state section, restored separately) are absent from
2335
+ * the extracted map; they fall back to `0n`, matching the prior placeholder
2336
+ * behaviour, and are immediately overwritten by `extractStateFromScript`.
2337
+ *
2338
+ * FixedArray constructor params are rejected by the compiler ("Constructor
2339
+ * parameter cannot be a FixedArray"), so no FixedArray grouping arises here.
2340
+ */
2341
+ function restoreConstructorArgs(artifact, scriptHex) {
2342
+ const params = artifact.abi.constructor.params;
2343
+ if (params.length === 0)
2344
+ return [];
2345
+ const extracted = extractConstructorArgs(artifact, scriptHex);
2346
+ return params.map((p) => (Object.prototype.hasOwnProperty.call(extracted, p.name) ? extracted[p.name] : 0n));
2347
+ }
2348
+ /**
2349
+ * Build a named argument map from positional args and user-visible params.
2350
+ * Used to feed the ANF interpreter for auto-state computation.
2351
+ */
2352
+ function buildNamedArgs(userParams, resolvedArgs) {
2353
+ const named = {};
2354
+ for (let i = 0; i < userParams.length; i++) {
2355
+ named[userParams[i].name] = resolvedArgs[i];
2356
+ }
2357
+ return named;
2358
+ }
2359
+ // ---------------------------------------------------------------------------
2360
+ // Encoding helpers
2361
+ // ---------------------------------------------------------------------------
2362
+ /**
2363
+ * Compute the BIP-143 sighash digest — `hash256(preimage)` i.e.
2364
+ * `sha256(sha256(preimage))` — that is ACTUALLY ECDSA-signed by
2365
+ * `OP_CHECKSIG` on-chain.
2366
+ *
2367
+ * Deep-review finding C19: `PreparedCall.sighash` previously stored only
2368
+ * `sha256(preimage)` (a SINGLE hash). That happened to work for the default
2369
+ * `call()` path only because `LocalSigner.sign()` hands its OWN independently
2370
+ * computed `sha256(preimage)` to `PrivateKey.sign()`, which re-hashes its
2371
+ * input once more internally — so the total ends up correct by accident of
2372
+ * that one call chain. An external wallet / hardware signer wired to the
2373
+ * multi-signer `prepareCall()` / `finalizeCall()` API is handed
2374
+ * `PreparedCall.sighash` and is expected to ECDSA-sign it DIRECTLY (no
2375
+ * further hashing) — e.g. a BRC-100 `signHash`-style API. Handed the
2376
+ * single-hashed value, such a signer signs the wrong digest and the node's
2377
+ * real `OP_CHECKSIG` rejects the spend.
2378
+ *
2379
+ * This is the WIRE-LEVEL digest external signers must sign — the same value
2380
+ * the Java SDK already stores in its `PreparedCall`. Port this exact
2381
+ * `hash256(preimage)` computation (not `sha256(preimage)`) to every other
2382
+ * tier's `PreparedCall.sighash` equivalent (Go/Rust/Python).
2383
+ */
2384
+ function computeBip143Sighash(preimageHex) {
2385
+ const preimageBytes = Utils.toArray(preimageHex, 'hex');
2386
+ return Utils.toHex(Hash.hash256(preimageBytes));
2387
+ }
2388
+ /**
2389
+ * Encode an argument value as a Bitcoin Script push data element.
2390
+ *
2391
+ * Exported as part of the low-level assembly surface: downstream tooling
2392
+ * (runar-testing's mock-preimage builders) and multi-contract tx assembly (and
2393
+ * any external unlocking-script construction) must encode arguments
2394
+ * byte-identically to `buildUnlockingScript` — share this, don't copy it.
2395
+ */
2396
+ export function encodeArg(value) {
2397
+ if (isEmptySig(value)) {
2398
+ return '00'; // OP_0 — empty signature push for the failing OR-CHECKSIG branch (issue #106)
2399
+ }
2400
+ if (typeof value === 'bigint') {
2401
+ return encodeScriptNumber(value);
2402
+ }
2403
+ if (typeof value === 'number') {
2404
+ return encodeScriptNumber(BigInt(value));
2405
+ }
2406
+ if (typeof value === 'boolean') {
2407
+ return value ? '51' : '00';
2408
+ }
2409
+ if (typeof value === 'string') {
2410
+ // Assume hex-encoded data
2411
+ return encodePushData(value);
2412
+ }
2413
+ // Fallback: convert to string
2414
+ return encodePushData(String(value));
2415
+ }
2416
+ /**
2417
+ * Encode a bigint as a minimally-encoded Bitcoin Script number push
2418
+ * (OP_0 / OP_1..OP_16 / OP_1NEGATE / sign-magnitude LE push data).
2419
+ * Exported as part of the low-level assembly surface.
2420
+ */
2421
+ export function encodeScriptNumber(n) {
2422
+ if (n === 0n) {
2423
+ return '00'; // OP_0
2424
+ }
2425
+ if (n >= 1n && n <= 16n) {
2426
+ // OP_1 through OP_16
2427
+ return (0x50 + Number(n)).toString(16);
2428
+ }
2429
+ if (n === -1n) {
2430
+ return '4f'; // OP_1NEGATE
2431
+ }
2432
+ const negative = n < 0n;
2433
+ let absVal = negative ? -n : n;
2434
+ const bytes = [];
2435
+ while (absVal > 0n) {
2436
+ bytes.push(Number(absVal & 0xffn));
2437
+ absVal >>= 8n;
2438
+ }
2439
+ if ((bytes[bytes.length - 1] & 0x80) !== 0) {
2440
+ bytes.push(negative ? 0x80 : 0x00);
2441
+ }
2442
+ else if (negative) {
2443
+ bytes[bytes.length - 1] |= 0x80;
2444
+ }
2445
+ const hex = bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
2446
+ return encodePushData(hex);
2447
+ }
2448
+ /**
2449
+ * Normalize a witness-value input (hex string or Uint8Array) into a
2450
+ * lowercase hex string suitable for `encodePushData`. Hex inputs may
2451
+ * optionally carry a `0x` prefix and any casing.
2452
+ */
2453
+ function normalizeWitnessBytes(value) {
2454
+ if (typeof value === 'string') {
2455
+ const h = value.startsWith('0x') || value.startsWith('0X') ? value.slice(2) : value;
2456
+ if (h.length % 2 !== 0) {
2457
+ throw new Error(`witness value: hex string must have even length (got ${h.length})`);
2458
+ }
2459
+ if (!/^[0-9a-fA-F]*$/.test(h)) {
2460
+ throw new Error('witness value: invalid hex characters');
2461
+ }
2462
+ return h.toLowerCase();
2463
+ }
2464
+ // Uint8Array
2465
+ let out = '';
2466
+ for (let i = 0; i < value.length; i++) {
2467
+ out += value[i].toString(16).padStart(2, '0');
2468
+ }
2469
+ return out;
2470
+ }
2471
+ /**
2472
+ * Encode raw hex bytes as a Bitcoin Script push (direct push / PUSHDATA1/2/4).
2473
+ * Exported as part of the low-level assembly surface.
2474
+ */
2475
+ export function encodePushData(dataHex) {
2476
+ if (dataHex.length === 0)
2477
+ return '00'; // OP_0
2478
+ const len = dataHex.length / 2;
2479
+ // MINIMALDATA: single-byte payloads in the OP_N range must use the
2480
+ // corresponding minimal opcode (`OP_1..OP_16` / `OP_1NEGATE`) rather than
2481
+ // the direct push `01 NN`, which is relay-rejected as "Data push larger
2482
+ // than necessary". `encodeScriptNumber` already short-circuits Int args to
2483
+ // OP_N; this brings the ByteString push path to the same standard. Kept
2484
+ // byte-identical with `encodePushDataState` in state.ts and the shared
2485
+ // `encode_push_data` in the other six SDKs.
2486
+ //
2487
+ // NOTE: 0x00 is deliberately NOT in that set. `OP_0` pushes the EMPTY byte
2488
+ // array, not a 1-byte `0x00` — so the minimal encoding of a 1-byte `0x00`
2489
+ // payload is the direct push `01 00` (matching the compiler's
2490
+ // `encodePushBytesHex` in push-encoding.ts), not `OP_0` (S1).
2491
+ if (len === 1) {
2492
+ const byte = parseInt(dataHex, 16);
2493
+ if (byte >= 0x01 && byte <= 0x10)
2494
+ return (0x50 + byte).toString(16).padStart(2, '0'); // OP_1..OP_16
2495
+ if (byte === 0x81)
2496
+ return '4f'; // OP_1NEGATE
2497
+ }
2498
+ if (len <= 75) {
2499
+ return len.toString(16).padStart(2, '0') + dataHex;
2500
+ }
2501
+ else if (len <= 0xff) {
2502
+ return '4c' + len.toString(16).padStart(2, '0') + dataHex;
2503
+ }
2504
+ else if (len <= 0xffff) {
2505
+ const lo = (len & 0xff).toString(16).padStart(2, '0');
2506
+ const hi = ((len >> 8) & 0xff).toString(16).padStart(2, '0');
2507
+ return '4d' + lo + hi + dataHex;
2508
+ }
2509
+ else {
2510
+ const b0 = (len & 0xff).toString(16).padStart(2, '0');
2511
+ const b1 = ((len >> 8) & 0xff).toString(16).padStart(2, '0');
2512
+ const b2 = ((len >> 16) & 0xff).toString(16).padStart(2, '0');
2513
+ const b3 = ((len >> 24) & 0xff).toString(16).padStart(2, '0');
2514
+ return '4e' + b0 + b1 + b2 + b3 + dataHex;
2515
+ }
2516
+ }