@qntx/spark 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,36 @@ is kept, and `withdrawals.unilateralExit` on that leaf reports the precise failu
27
27
  Key custody: private keys never leave the `SparkSigner` — the client only ever sees protocol
28
28
  values (public keys, signatures, ciphertexts).
29
29
 
30
+ Outflow verification: `createSparkSigner` accepts an optional
31
+ `authorize?: (outflow: SparkOutflow) => Promise<void>` gate. Before every signature that sends
32
+ value out, the signer independently derives the outflow — it never trusts caller-supplied
33
+ amounts or receivers. Authorization happens at two points, each backed by what the SO enforces:
34
+
35
+ - `signFrost` checks every job before producing any share. A target spending a foreign input
36
+ (connector refund) is an `exit` candidate: the job must carry `exit: { connectorTx, exitTx }`,
37
+ the signer checks that the connector spends `exitTx` and matches the foreign input, and the
38
+ outflow reports the payout's `exitOutputs`. Every other output must be an ephemeral anchor, the
39
+ spent prevout's own script, the signer's identity, a pending transfer's recorded receiver, or
40
+ — for `leaf`/`static-deposit` keys only — the job's own key. A single-input target with any
41
+ other output is a `lightning` candidate carrying that output script (`lightningSendHtlcScript`
42
+ reproduces it for an invoice's payment hash); a multi-input one is rejected, as is a batch
43
+ mixing candidates. Candidates are authorized before any share is produced, since a signed
44
+ package can later be paired with any shares the signer issued.
45
+ - `signIdentity` on `transfer-package` signs only when the package's `key_tweak_package` is
46
+ byte-equal to what `prepareTransfer` sealed this session and every leaf has a recorded refund
47
+ signature (the amount is the per-key maximum the SO verifies end-to-end). Receivers equal to
48
+ the bound SSP report `swap` — or nothing when `signFrost` already authorized every leaf (a
49
+ partially authorized SSP transfer is rejected); any other receiver reports `transfer` with
50
+ per-receiver totals.
51
+
52
+ `signKeySpend`, `signHtlcTimeout` and the
53
+ `static-deposit-refund`/`watchtower-recovery` statements report `onchain` outputs decoded from
54
+ the transaction being signed plus `inputSats` (the spent prevout total, so callers derive the
55
+ fee); `token-transaction` reports `token` outputs with u128 amounts as
56
+ `bigint`. A throwing `authorize` aborts the signature; records stay for retry. Session records
57
+ are bounded (4,096 signing keys, 256 prepared transfers, oldest evicted) and wiped on
58
+ `dispose()`.
59
+
30
60
  Idempotent retries: `transfers.send`/`sendMany`, `lightning.pay` and
31
61
  `withdrawals.cooperativeExit` accept a `transferId` — reuse the same id to retry a call whose
32
62
  response may have committed; the same id with different terms fails rather than paying twice.
@@ -292,12 +292,4 @@ function isExpired(invoice, nowMs) {
292
292
  return nowMs > (invoice.timestamp + invoice.expirySeconds) * 1e3;
293
293
  }
294
294
  //#endregion
295
- //#region src/core/text.ts
296
- const CONTROL = /[\u0000-\u001F\u007F-\u009F]/g;
297
- /** Server-supplied error text made safe to embed in an error message. */
298
- function serverErrorText(text) {
299
- const cleaned = text.replace(CONTROL, "");
300
- return cleaned.length <= 256 ? cleaned : `${cleaned.slice(0, 256)}\u2026`;
301
- }
302
- //#endregion
303
- export { decodeBolt11 as n, isExpired as r, serverErrorText as t };
295
+ export { isExpired as n, decodeBolt11 as t };
@@ -1,10 +1,9 @@
1
1
  import { i as SparkOperatorConfig, n as SparkNetwork, o as Signature, r as SparkNetworkConfig } from "./lightning-address-CRULmi9v.mjs";
2
- import { g as SparkSigner } from "./types-jBCznYd6.mjs";
2
+ import { v as SparkSigner } from "./transactions-DjTZU8ev.mjs";
3
3
  import "@bufbuild/protobuf/codegenv2";
4
4
  import "@bufbuild/protobuf/wkt";
5
5
  import { Message, UnknownEnum } from "@bufbuild/protobuf";
6
6
  import "@connectrpc/connect";
7
- import "@scure/btc-signer/utils.js";
8
7
  //#region src/connection/index.d.ts
9
8
  /**
10
9
  * A Spark protocol event delivered by the coordinator's `subscribe_to_events` stream. Plain types;
package/dist/index.d.mts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { a as SparkTokenBroadcastError, i as SparkRequestError, n as SparkError, r as SparkInputError, t as SparkCryptoError } from "./index-D1fBaHTa.mjs";
2
2
  import { a as sparkNetworkConfig, i as SparkOperatorConfig, n as SparkNetwork, r as SparkNetworkConfig } from "./lightning-address-CRULmi9v.mjs";
3
3
  import { i as isExpired, n as Bolt11Network, r as decodeBolt11, t as Bolt11Invoice } from "./bolt11-DsNTTS_O.mjs";
4
- import { A as CooperativeExitStatus, C as exitTransactions, D as CooperativeExitQuote, E as createMemorySparkStore, F as UnusedDepositAddress, I as SparkLeaf, L as SparkEvent, M as StaticDepositClaim, N as StaticDepositRefund, O as CooperativeExitResult, P as StaticDepositUtxo, S as ExitTransaction, T as SparkStore, _ as LightningSendStatus, a as createSparkClient, b as LightningInvoice, c as buildCpfpChild, d as SparkTokenBalance, f as SparkTokenOutput, g as LightningSendOutcome, h as LightningReceiveStatus, i as SparkInvoiceStatus, j as MIN_STATIC_REFUND_FEE_SATS, k as CooperativeExitSpeed, l as SparkTransfer, m as SparkWalletSettings, n as SparkClient, o as StaticDepositClaimResult, p as SparkTokenOutputStatus, r as SparkClientOptions, s as FundingInput, t as ClaimPendingResult, u as SparkTransferLeaf, v as LightningSendStatusRequest, w as SparkExitChain, x as WatchtowerExitedLeaf, y as LightningPayment } from "./index-B_g3vHnT.mjs";
5
- import { g as SparkSigner } from "./types-jBCznYd6.mjs";
4
+ import { A as CooperativeExitStatus, C as exitTransactions, D as CooperativeExitQuote, E as createMemorySparkStore, F as UnusedDepositAddress, I as SparkLeaf, L as SparkEvent, M as StaticDepositClaim, N as StaticDepositRefund, O as CooperativeExitResult, P as StaticDepositUtxo, S as ExitTransaction, T as SparkStore, _ as LightningSendStatus, a as createSparkClient, b as LightningInvoice, c as buildCpfpChild, d as SparkTokenBalance, f as SparkTokenOutput, g as LightningSendOutcome, h as LightningReceiveStatus, i as SparkInvoiceStatus, j as MIN_STATIC_REFUND_FEE_SATS, k as CooperativeExitSpeed, l as SparkTransfer, m as SparkWalletSettings, n as SparkClient, o as StaticDepositClaimResult, p as SparkTokenOutputStatus, r as SparkClientOptions, s as FundingInput, t as ClaimPendingResult, u as SparkTransferLeaf, v as LightningSendStatusRequest, w as SparkExitChain, x as WatchtowerExitedLeaf, y as LightningPayment } from "./index-DISbAuPB.mjs";
5
+ import { n as EPHEMERAL_ANCHOR_SCRIPT, t as addressToScript, v as SparkSigner } from "./transactions-DjTZU8ev.mjs";
6
6
  //#region src/core/invoice.d.ts
7
7
  /** Payment carried by a Spark invoice: satoshis or BTKN tokens. */
8
8
  type SparkPayment = {
@@ -68,6 +68,14 @@ export declare function encodeTokenIdentifier(tokenIdentifier: Uint8Array, netwo
68
68
  */
69
69
  export declare function decodeTokenIdentifier(text: string): SparkTokenIdentifierData;
70
70
  //#endregion
71
+ //#region src/core/uuid.d.ts
72
+ /**
73
+ * UUIDv7 (RFC 9562 §5.7): 48-bit millisecond timestamp, then 74 random bits with the version (0111)
74
+ * and variant (10) fields applied. Randomness and the clock are injected so the function stays
75
+ * platform-neutral and deterministic under test.
76
+ */
77
+ export declare function uuidv7(random: (length: number) => Uint8Array, now?: () => number): string;
78
+ //#endregion
71
79
  //#region src/invoice/create.d.ts
72
80
  type InvoiceOptions = {
73
81
  readonly signer: SparkSigner;
@@ -112,4 +120,4 @@ export declare function parseSparkInvoice(text: string, options: {
112
120
  readonly now: number;
113
121
  }): ParsedSparkInvoice;
114
122
  //#endregion
115
- export { type Bolt11Invoice, type Bolt11Network, type ClaimPendingResult, type CooperativeExitQuote, type CooperativeExitResult, type CooperativeExitSpeed, type CooperativeExitStatus, type ExitTransaction, type FundingInput, type LightningInvoice, type LightningPayment, type LightningReceiveStatus, type LightningSendOutcome, type LightningSendStatus, type LightningSendStatusRequest, MIN_STATIC_REFUND_FEE_SATS, type ParsedSparkInvoice, type SparkAddress, type SparkClient, type SparkClientOptions, SparkCryptoError, SparkError, type SparkEvent, type SparkExitChain, SparkInputError, type SparkInvoiceFields, type SparkInvoiceStatus, type SparkLeaf, type SparkNetwork, type SparkNetworkConfig, type SparkOperatorConfig, type SparkPayment, SparkRequestError, type SparkStore, type SparkTokenBalance, SparkTokenBroadcastError, type SparkTokenIdentifierData, type SparkTokenOutput, type SparkTokenOutputStatus, type SparkTransfer, type SparkTransferLeaf, type SparkWalletSettings, type StaticDepositClaim, type StaticDepositClaimResult, type StaticDepositRefund, type StaticDepositUtxo, type UnusedDepositAddress, type WatchtowerExitedLeaf, buildCpfpChild, createMemorySparkStore, createSparkClient, decodeBolt11, exitTransactions, isExpired, sparkNetworkConfig };
123
+ export { type Bolt11Invoice, type Bolt11Network, type ClaimPendingResult, type CooperativeExitQuote, type CooperativeExitResult, type CooperativeExitSpeed, type CooperativeExitStatus, EPHEMERAL_ANCHOR_SCRIPT, type ExitTransaction, type FundingInput, type LightningInvoice, type LightningPayment, type LightningReceiveStatus, type LightningSendOutcome, type LightningSendStatus, type LightningSendStatusRequest, MIN_STATIC_REFUND_FEE_SATS, type ParsedSparkInvoice, type SparkAddress, type SparkClient, type SparkClientOptions, SparkCryptoError, SparkError, type SparkEvent, type SparkExitChain, SparkInputError, type SparkInvoiceFields, type SparkInvoiceStatus, type SparkLeaf, type SparkNetwork, type SparkNetworkConfig, type SparkOperatorConfig, type SparkPayment, SparkRequestError, type SparkStore, type SparkTokenBalance, SparkTokenBroadcastError, type SparkTokenIdentifierData, type SparkTokenOutput, type SparkTokenOutputStatus, type SparkTransfer, type SparkTransferLeaf, type SparkWalletSettings, type StaticDepositClaim, type StaticDepositClaimResult, type StaticDepositRefund, type StaticDepositUtxo, type UnusedDepositAddress, type WatchtowerExitedLeaf, addressToScript, buildCpfpChild, createMemorySparkStore, createSparkClient, decodeBolt11, exitTransactions, isExpired, sparkNetworkConfig };
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { a as SparkTokenBroadcastError, i as SparkRequestError, n as SparkError, r as SparkInputError, t as SparkCryptoError } from "./errors-5xLkExNL.mjs";
2
2
  import { $ as SparkAuthnService, A as EPHEMERAL_ANCHOR_SCRIPT, B as parseTransaction, Bt as TransferReceiverStatus, C as createInitialTimelockRefundTxs, Ct as InitiatePreimageSwapRequest_Reason, D as txId, Dt as RenewNodeTimelockSigningJobSchema, E as estimateVbytes, Et as Order, Ft as SparkAddressSchema, G as PartialTokenOutputSchema, Gt as SignatureScheme, Ht as TransferType, I as doesTxnNeedRenewed, It as SparkInvoiceFieldsSchema, J as SparkTokenService, K as PartialTokenTransactionSchema, L as getNextHTLCTransactionSequence, Lt as SparkService, M as computeTaprootKeyNoScript, N as p2trScriptFromPublicKey, Nt as SenderTransferPackageSchema, O as txIdInputBytes, Ot as RenewNodeZeroTimelockSigningJobSchema, P as xOnlyPublicKey, Pt as SigningJobSchema, Q as ChallengeSchema, R as getTimelockFromSequence, Rt as StartTransferRequestSchema, S as createInitialTimelockNodeTx, St as HashVariant, T as createZeroTimelockNodeTx, Tt as InvoiceStatus, U as CommitStatus, Ut as TreeNodeStatus, V as taprootKeySpendSighash, Vt as TransferStatus, W as InvoiceAttachmentSchema, Wt as UserSignedTxSigningJobSchema, X as TokenTransactionMetadataSchema, Y as TokenOutputStatus, Z as TokenTransactionStatus, _ as addressToScript, _t as AdaptorPublicKeyPackageSchema, a as proofOfPossessionDigest, at as uuidToBytes, b as createDecrementedTimelockNodeTx, bt as ClaimPackageSchema, d as LIGHTNING_HTLC_SEQUENCE, dt as assertU32, et as hashSparkInvoice, f as constructHtlcSenderSpend, ft as requireLength, g as lightningHtlcOutputScript, gt as sparkNetworkProto, h as finalizeHtlcSpend, ht as sparkNetworkFromProto, it as uuidFromBytes, j as btcNetwork, k as verifyFinalizedTaprootSignature, kt as RenewRefundTimelockSigningJobSchema, lt as assertCompressedKey, mt as sparkNetworkConfig, nt as protoInvoiceToPlain, o as sendLeafSigningDigest, ot as uuidv7, p as createRefundTxsForLightning, pt as u64ToSats, q as SignatureWithIndexSchema, rt as validateInvoiceFields, st as compareBytes, t as hashFinalTokenTransaction, tt as encodeInvoiceFields, ut as assertSats, v as createConnectorRefundTxs, w as createMultiInputRootTx, wt as InputSigningDataSchema, x as createDecrementedTimelockRefundTxs, xt as Direction, y as createCurrentTimelockRefundTxs, z as isZeroTimelock, zt as TransferPackageSchema } from "./token-DmojVTWi.mjs";
3
- import { n as decodeBolt11, r as isExpired, t as serverErrorText } from "./text-DC_Vptn6.mjs";
3
+ import { n as isExpired, t as decodeBolt11 } from "./bolt11-rfxnyLcb.mjs";
4
+ import { t as serverErrorText } from "./text-CDhN7qo4.mjs";
4
5
  import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
5
6
  import { timestampDate, timestampFromDate, timestampFromMs } from "@bufbuild/protobuf/wkt";
6
7
  import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
@@ -2413,7 +2414,8 @@ async function signUserJobs(signer, specs) {
2413
2414
  verifyingKey: spec.verifyingKey,
2414
2415
  commitment,
2415
2416
  operatorCommitments: operatorCommitments(spec.commitments),
2416
- ...spec.adaptorPublicKey === void 0 ? {} : { adaptorPublicKey: spec.adaptorPublicKey }
2417
+ ...spec.adaptorPublicKey === void 0 ? {} : { adaptorPublicKey: spec.adaptorPublicKey },
2418
+ ...spec.exit === void 0 ? {} : { exit: spec.exit }
2417
2419
  };
2418
2420
  }));
2419
2421
  if (shares.length !== chunk.length) throw new SparkError("signUserJobs: signer returned fewer shares than requested");
@@ -3917,7 +3919,7 @@ function refundJobGroups(leaves, buildRefunds, signingCommitments, context) {
3917
3919
  const directFromCpfp = [];
3918
3920
  for (const [i, entry] of leaves.entries()) {
3919
3921
  const nodeOutput = prevout(entry.leaf.nodeTx, 0, `${context} leaf ${entry.leaf.id} node tx`);
3920
- const { refunds, extraPrevout } = buildRefunds(entry, refundTxSequence(entry.refundTx, context));
3922
+ const { refunds, extraPrevout, exit } = buildRefunds(entry, refundTxSequence(entry.refundTx, context));
3921
3923
  const commitments = signingCommitments[i];
3922
3924
  const directCommitment = signingCommitments[count + i];
3923
3925
  const directFromCpfpCommitment = signingCommitments[2 * count + i];
@@ -3935,7 +3937,8 @@ function refundJobGroups(leaves, buildRefunds, signingCommitments, context) {
3935
3937
  inputIndex: 0,
3936
3938
  prevouts: extraPrevout === void 0 ? [nodeOutput] : [nodeOutput, extraPrevout]
3937
3939
  },
3938
- commitments
3940
+ commitments,
3941
+ ...exit === void 0 ? {} : { exit }
3939
3942
  });
3940
3943
  if (refunds.directRefund !== void 0) {
3941
3944
  const directOutput = prevout(entry.leaf.directTx ?? /* @__PURE__ */ new Uint8Array(), 0, `${context} leaf ${entry.leaf.id} direct node tx`);
@@ -3952,7 +3955,8 @@ function refundJobGroups(leaves, buildRefunds, signingCommitments, context) {
3952
3955
  inputIndex: 0,
3953
3956
  prevouts: extraPrevout === void 0 ? [directOutput] : [directOutput, extraPrevout]
3954
3957
  },
3955
- commitments: directCommitment
3958
+ commitments: directCommitment,
3959
+ ...exit === void 0 ? {} : { exit }
3956
3960
  });
3957
3961
  }
3958
3962
  directFromCpfp.push({
@@ -3968,7 +3972,8 @@ function refundJobGroups(leaves, buildRefunds, signingCommitments, context) {
3968
3972
  inputIndex: 0,
3969
3973
  prevouts: extraPrevout === void 0 ? [nodeOutput] : [nodeOutput, extraPrevout]
3970
3974
  },
3971
- commitments: directFromCpfpCommitment
3975
+ commitments: directFromCpfpCommitment,
3976
+ ...exit === void 0 ? {} : { exit }
3972
3977
  });
3973
3978
  }
3974
3979
  return {
@@ -6824,7 +6829,11 @@ async function cooperativeExit(deps, options) {
6824
6829
  directFromCpfpRefund: job(txs.directFromCpfpRefundTx, nodePrevout),
6825
6830
  directRefund: txs.directRefundTx === void 0 || entry.leaf.directTx === void 0 ? void 0 : job(txs.directRefundTx, prevout0(entry.leaf.directTx, `leaf ${entry.leaf.id} direct node tx`))
6826
6831
  },
6827
- extraPrevout: connectorPrevout
6832
+ extraPrevout: connectorPrevout,
6833
+ exit: {
6834
+ connectorTx: request.rawConnectorTransaction,
6835
+ exitTx: request.rawCoopExitTransaction
6836
+ }
6828
6837
  };
6829
6838
  }
6830
6839
  });
@@ -7319,4 +7328,4 @@ function createMemorySparkStore() {
7319
7328
  };
7320
7329
  }
7321
7330
  //#endregion
7322
- export { MIN_STATIC_REFUND_FEE_SATS, SparkCryptoError, SparkError, SparkInputError, SparkRequestError, SparkTokenBroadcastError, buildCpfpChild, createMemorySparkStore, createSatsInvoice, createSparkClient, createTokensInvoice, decodeBolt11, decodeSparkAddress, decodeTokenIdentifier, encodeSparkAddress, encodeTokenIdentifier, exitTransactions, isExpired, parseSparkInvoice, sparkNetworkConfig, verifySparkAddressSignature };
7331
+ export { EPHEMERAL_ANCHOR_SCRIPT, MIN_STATIC_REFUND_FEE_SATS, SparkCryptoError, SparkError, SparkInputError, SparkRequestError, SparkTokenBroadcastError, addressToScript, buildCpfpChild, createMemorySparkStore, createSatsInvoice, createSparkClient, createTokensInvoice, decodeBolt11, decodeSparkAddress, decodeTokenIdentifier, encodeSparkAddress, encodeTokenIdentifier, exitTransactions, isExpired, parseSparkInvoice, sparkNetworkConfig, uuidv7, verifySparkAddressSignature };
@@ -1,5 +1,6 @@
1
1
  import { i as SparkRequestError, r as SparkInputError } from "./errors-5xLkExNL.mjs";
2
- import { n as decodeBolt11, t as serverErrorText } from "./text-DC_Vptn6.mjs";
2
+ import { t as decodeBolt11 } from "./bolt11-rfxnyLcb.mjs";
3
+ import { t as serverErrorText } from "./text-CDhN7qo4.mjs";
3
4
  import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
4
5
  import { base64, bech32 } from "@scure/base";
5
6
  import { sha256 } from "@noble/hashes/sha2.js";
package/dist/lnurl.mjs CHANGED
@@ -1 +1 @@
1
- export { f as checkLnurlUrl, p as decodeLnurl, u as decryptSuccessAction, m as encodeLnurl, t as lightningAddressRegisterBody, n as lightningAddressUnregisterBody, h as lightningAddressUrl, y as lnurlGetJson, b as lnurlSendJson, g as parseLnurlAuth, c as parseLnurlPayRequest, o as parseLnurlWithdrawRequest, d as parseSuccessAction, r as registerLightningAddress, l as requestInvoice, a as resolveLnurl, _ as submitLnurlAuth, s as submitWithdraw, i as unregisterLightningAddress } from "./lnurl-kcdXU75g.mjs";
1
+ export { f as checkLnurlUrl, p as decodeLnurl, u as decryptSuccessAction, m as encodeLnurl, t as lightningAddressRegisterBody, n as lightningAddressUnregisterBody, h as lightningAddressUrl, y as lnurlGetJson, b as lnurlSendJson, g as parseLnurlAuth, c as parseLnurlPayRequest, o as parseLnurlWithdrawRequest, d as parseSuccessAction, r as registerLightningAddress, l as requestInvoice, a as resolveLnurl, _ as submitLnurlAuth, s as submitWithdraw, i as unregisterLightningAddress } from "./lnurl-BhEGaRok.mjs";
package/dist/server.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as SparkClient } from "./index-B_g3vHnT.mjs";
1
+ import { n as SparkClient } from "./index-DISbAuPB.mjs";
2
2
  //#region src/server/store.d.ts
3
3
  type LightningAddressUser = {
4
4
  /** Lowercase LUD-16 local-part. */
package/dist/server.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { r as SparkInputError } from "./errors-5xLkExNL.mjs";
2
- import { t as serverErrorText } from "./text-DC_Vptn6.mjs";
3
2
  import { a as lightningAddressRegisterMessage, c as lightningAddressUnregisterMessage, i as lightningAddressMessageDigest, n as checkLightningAddressDomain, o as lightningAddressStatementHash, s as lightningAddressTimestampFresh, t as LIGHTNING_ADDRESS_USERNAME_PATTERN } from "./lightning-address-CZrb8x4p.mjs";
4
- import { m as encodeLnurl, v as isJsonObject } from "./lnurl-kcdXU75g.mjs";
3
+ import { t as serverErrorText } from "./text-CDhN7qo4.mjs";
4
+ import { m as encodeLnurl, v as isJsonObject } from "./lnurl-BhEGaRok.mjs";
5
5
  import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
6
6
  import { secp256k1 } from "@noble/curves/secp256k1.js";
7
7
  import { sha256 } from "@noble/hashes/sha2.js";
package/dist/signer.d.mts CHANGED
@@ -1,7 +1,71 @@
1
1
  import { n as SparkCrypto } from "./index-DUSx3L3J.mjs";
2
- import { n as SparkNetwork } from "./lightning-address-CRULmi9v.mjs";
3
- import { _ as StaticDepositClaimRequest, a as OperatorCommitment, c as PreparedClaim, d as PreparedTransferLeaf, f as SigningCommitment, g as SparkSigner, h as SparkOperator, i as IdentityStatement, l as PreparedLightningReceive, m as SparkLeafKey, n as FrostSignJob, o as PrepareClaimRequest, p as SparkKey, r as HtlcTimeoutSignRequest, s as PrepareTransferRequest, t as FrostAggregateJob, u as PreparedTransfer, v as SigningTarget } from "./types-jBCznYd6.mjs";
2
+ import { a as sparkNetworkConfig, n as SparkNetwork } from "./lightning-address-CRULmi9v.mjs";
3
+ import { r as decodeBolt11 } from "./bolt11-DsNTTS_O.mjs";
4
+ import { _ as SparkOperator, a as HtlcTimeoutSignRequest, b as SigningTarget, c as PrepareClaimRequest, d as PreparedLightningReceive, f as PreparedTransfer, g as SparkLeafKey, h as SparkKey, i as FrostSignJob, l as PrepareTransferRequest, m as SigningCommitment, n as EPHEMERAL_ANCHOR_SCRIPT, o as IdentityStatement, p as PreparedTransferLeaf, r as FrostAggregateJob, s as OperatorCommitment, t as addressToScript, u as PreparedClaim, v as SparkSigner, y as StaticDepositClaimRequest } from "./transactions-DjTZU8ev.mjs";
4
5
  import { secp256k1 } from "@noble/curves/secp256k1.js";
6
+ //#region src/signer/outflow.d.ts
7
+ /** What an authorized signature sends out, derived inside the signer (never caller-supplied). */
8
+ type SparkOutflow = {
9
+ readonly kind: "transfer";
10
+ readonly transferId: string;
11
+ readonly totalSats: number;
12
+ readonly receivers: ReadonlyArray<{
13
+ readonly identityPublicKey: Uint8Array;
14
+ readonly amountSats: number;
15
+ }>;
16
+ } | {
17
+ readonly kind: "lightning";
18
+ readonly htlcOutputScript: Uint8Array;
19
+ readonly totalSats: number;
20
+ readonly leaves: ReadonlyArray<{
21
+ readonly publicKey: Uint8Array;
22
+ readonly amountSats: number;
23
+ }>;
24
+ } | {
25
+ readonly kind: "exit";
26
+ readonly connectorTxid: Uint8Array;
27
+ readonly totalSats: number;
28
+ readonly leaves: ReadonlyArray<{
29
+ readonly publicKey: Uint8Array;
30
+ readonly amountSats: number;
31
+ }>;
32
+ /** Every output of the SSP-provided coop-exit payout transaction. */
33
+ readonly exitOutputs: ReadonlyArray<{
34
+ readonly script: Uint8Array;
35
+ readonly amountSats: number;
36
+ }>;
37
+ } | {
38
+ readonly kind: "swap";
39
+ readonly transferId: string;
40
+ readonly totalSats: number;
41
+ } | {
42
+ readonly kind: "onchain";
43
+ readonly source: "static-deposit-refund" | "watchtower-recovery" | "htlc-timeout" | "key-spend";
44
+ readonly totalSats: number;
45
+ readonly inputSats: number;
46
+ readonly outputs: ReadonlyArray<{
47
+ readonly script: Uint8Array;
48
+ readonly amountSats: number;
49
+ }>;
50
+ } | {
51
+ readonly kind: "token";
52
+ readonly outputs: ReadonlyArray<{
53
+ readonly tokenIdentifier: Uint8Array;
54
+ readonly ownerPublicKey: Uint8Array;
55
+ readonly amount: bigint;
56
+ }>;
57
+ };
58
+ /**
59
+ * The output-0 script of a lightning send's HTLC refund: hash lock to the SSP, sequence lock to the
60
+ * sender identity — the same script `createRefundTxsForLightning` commits to.
61
+ */
62
+ export declare function lightningSendHtlcScript(options: {
63
+ readonly paymentHash: Uint8Array;
64
+ readonly sspIdentityPublicKey: Uint8Array;
65
+ readonly identityPublicKey: Uint8Array;
66
+ }): Uint8Array;
67
+ type AuthorizeOutflow = (outflow: SparkOutflow) => Promise<void>;
68
+ //#endregion
5
69
  //#region src/signer/keys.d.ts
6
70
  /**
7
71
  * The 64-byte extended private key (private key ‖ chain code) at `m/8797555'/account'` — the input
@@ -36,6 +100,12 @@ export declare function createSparkSigner(options: {
36
100
  readonly threshold?: number;
37
101
  /** SSP identity the static-deposit secret is sealed to; defaults to the network's SSP key. */
38
102
  readonly sspIdentityPublicKey?: Uint8Array;
103
+ /**
104
+ * Optional gate invoked with the signer-derived {@link SparkOutflow} before every signature that
105
+ * sends value out (transfer packages, onchain spends, token transactions). Throwing aborts the
106
+ * signature; the records stay so a later authorize can be retried.
107
+ */
108
+ readonly authorize?: AuthorizeOutflow;
39
109
  }): SparkSigner;
40
110
  //#endregion
41
111
  //#region src/signer/lnurl-auth.d.ts
@@ -69,4 +139,4 @@ export declare function createLnurlAuthSigner(options: {
69
139
  readonly rootKey: Uint8Array;
70
140
  }): LnurlAuthSigner;
71
141
  //#endregion
72
- export type { FrostAggregateJob, FrostSignJob, HtlcTimeoutSignRequest, IdentityStatement, LnurlAuthSigner, OperatorCommitment, PrepareClaimRequest, PrepareTransferRequest, PreparedClaim, PreparedLightningReceive, PreparedTransfer, PreparedTransferLeaf, SigningCommitment, SigningTarget, SparkKey, SparkLeafKey, SparkNetwork, SparkOperator, SparkSigner, StaticDepositClaimRequest };
142
+ export { type AuthorizeOutflow, EPHEMERAL_ANCHOR_SCRIPT, type FrostAggregateJob, type FrostSignJob, type HtlcTimeoutSignRequest, type IdentityStatement, type LnurlAuthSigner, type OperatorCommitment, type PrepareClaimRequest, type PrepareTransferRequest, type PreparedClaim, type PreparedLightningReceive, type PreparedTransfer, type PreparedTransferLeaf, type SigningCommitment, type SigningTarget, type SparkKey, type SparkLeafKey, type SparkNetwork, type SparkOperator, type SparkOutflow, type SparkSigner, type StaticDepositClaimRequest, addressToScript, decodeBolt11, sparkNetworkConfig };
package/dist/signer.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { n as SparkError, r as SparkInputError, t as SparkCryptoError } from "./errors-5xLkExNL.mjs";
2
- import { At as SecretShareSchema, B as parseTransaction, F as checkIfValidSequence, H as taprootScriptSpendSighash, It as SparkInvoiceFieldsSchema, K as PartialTokenTransactionSchema, Mt as SendLeafKeyTweaksSchema, Q as ChallengeSchema, R as getTimelockFromSequence, V as taprootKeySpendSighash, bt as ClaimPackageSchema, c as staticDepositStatementDigest, ct as uint64be, d as LIGHTNING_HTLC_SEQUENCE, et as hashSparkInvoice, ft as requireLength, g as lightningHtlcOutputScript, i as claimPackageDigest, jt as SendLeafKeyTweakSchema, l as transferPackageDigest, m as createSequenceLockScript, mt as sparkNetworkConfig, n as hashPartialTokenTransaction, nt as protoInvoiceToPlain, o as sendLeafSigningDigest, r as authnChallengeDigest, s as sspChallengeDigest, u as watchtowerRecoveryStatementDigest, vt as ClaimLeafKeyTweakSchema, yt as ClaimLeafKeyTweaksSchema, zt as TransferPackageSchema } from "./token-DmojVTWi.mjs";
2
+ import { A as EPHEMERAL_ANCHOR_SCRIPT, At as SecretShareSchema, B as parseTransaction, F as checkIfValidSequence, H as taprootScriptSpendSighash, It as SparkInvoiceFieldsSchema, K as PartialTokenTransactionSchema, Mt as SendLeafKeyTweaksSchema, N as p2trScriptFromPublicKey, O as txIdInputBytes, Q as ChallengeSchema, R as getTimelockFromSequence, V as taprootKeySpendSighash, _ as addressToScript, bt as ClaimPackageSchema, c as staticDepositStatementDigest, ct as uint64be, d as LIGHTNING_HTLC_SEQUENCE, et as hashSparkInvoice, ft as requireLength, g as lightningHtlcOutputScript, i as claimPackageDigest, jt as SendLeafKeyTweakSchema, l as transferPackageDigest, m as createSequenceLockScript, mt as sparkNetworkConfig, n as hashPartialTokenTransaction, nt as protoInvoiceToPlain, o as sendLeafSigningDigest, r as authnChallengeDigest, s as sspChallengeDigest, u as watchtowerRecoveryStatementDigest, vt as ClaimLeafKeyTweakSchema, yt as ClaimLeafKeyTweaksSchema, zt as TransferPackageSchema } from "./token-DmojVTWi.mjs";
3
+ import { t as decodeBolt11 } from "./bolt11-rfxnyLcb.mjs";
3
4
  import { i as lightningAddressMessageDigest, r as lightningAddressMessage } from "./lightning-address-CZrb8x4p.mjs";
4
5
  import "./crypto.mjs";
5
6
  import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
@@ -7,6 +8,7 @@ import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
7
8
  import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
8
9
  import { schnorr, secp256k1 } from "@noble/curves/secp256k1.js";
9
10
  import { sha256 } from "@noble/hashes/sha2.js";
11
+ import { bytesToNumberBE } from "@noble/curves/utils.js";
10
12
  import { taprootTweakPrivKey } from "@scure/btc-signer/utils.js";
11
13
  import { HDKey } from "@scure/bip32";
12
14
  import { hmac } from "@noble/hashes/hmac.js";
@@ -434,6 +436,347 @@ function createNonceStore(crypto, accountPrivate, random) {
434
436
  };
435
437
  }
436
438
  //#endregion
439
+ //#region src/signer/outflow.ts
440
+ /**
441
+ * Signer-side outflow derivation: the signer independently computes what an authorized signature
442
+ * sends out, without trusting the caller. Authorization happens at two points, each backed by what
443
+ * the SO enforces:
444
+ *
445
+ * - `signFrost` per-job whitelist: a multi-input target whose inputs spend a foreign prevout script
446
+ * is a connector (cooperative-exit) candidate — its outputs are not checked (they pay the SSP),
447
+ * the job must carry the SSP's `{connectorTx, exitTx}` pair, the pair's txid must match the
448
+ * foreign input, connectorTx input 0 must spend exitTx, and every exit job in the batch must
449
+ * carry the same pair. Otherwise every output must be whitelisted: an ephemeral anchor, the spent
450
+ * prevout's own script (intra-tree node transactions), a P2TR to the signer's own identity, a
451
+ * P2TR to a pending prepared transfer's recorded receiver for that leaf, or — for
452
+ * `leaf`/`static-deposit` keys only — a P2TR to the job's own signing key. A single-input target
453
+ * outside the whitelist is a lightning (HTLC) candidate; a multi-input same-script target outside
454
+ * it fails. Exit and lightning candidates are authorized BEFORE any share is produced — the same
455
+ * signed package can be paired with any shares the signer ever issued, so HTLC/connector shares
456
+ * must never be issuable unchecked; a batch mixing the two kinds, or carrying more than one HTLC
457
+ * script or connector txid, is rejected outright.
458
+ * - `signIdentity({kind:"transfer-package"})`: the package's `key_tweak_package` must be byte-equal
459
+ * to this session's recorded `prepareTransfer`, every leaf needs a recorded refund signature
460
+ * (amount = per-pubkey max prevout), and the receivers decide `transfer`/`swap` — or no further
461
+ * authorization when a `signFrost` service authorization already covered the leaves (all
462
+ * receivers the bound SSP).
463
+ *
464
+ * Non-package outflows (onchain spends with `inputSats` for fee derivation, token transactions) are
465
+ * decoded from the transaction being signed. `authorize` runs before every outflow signature; a
466
+ * throw aborts it without consuming commitments.
467
+ *
468
+ * Upstream reference: sdks/js/packages/spark-sdk signer.ts / services/transfer.ts and
469
+ * so/handler/transfer_package_validation.go — buildonspark/spark@0b3a32a.
470
+ */
471
+ /**
472
+ * The output-0 script of a lightning send's HTLC refund: hash lock to the SSP, sequence lock to the
473
+ * sender identity — the same script `createRefundTxsForLightning` commits to.
474
+ */
475
+ function lightningSendHtlcScript(options) {
476
+ return lightningHtlcOutputScript(options.paymentHash, options.sspIdentityPublicKey, options.identityPublicKey);
477
+ }
478
+ const MAX_SIGNING_KEYS = 4096;
479
+ const MAX_TRANSFERS = 256;
480
+ const eq = (a, b) => a.length === b.length && a.every((byte, i) => byte === b[i]);
481
+ function outputScripts(tx) {
482
+ const scripts = [];
483
+ for (let i = 0; i < tx.outputsLength; i++) {
484
+ const output = tx.getOutput(i);
485
+ if (output.script === void 0) throw new SparkInputError("transaction: output carries no script");
486
+ scripts.push(output.script);
487
+ }
488
+ return scripts;
489
+ }
490
+ /**
491
+ * `onchain` outflow decoded from the transaction being signed; `inputSats` = the spent prevout
492
+ * amounts (one per input) so the consumer can derive the fee.
493
+ */
494
+ function onchainOutflow(source, target) {
495
+ const tx = parseTransaction(target.transaction);
496
+ if (target.prevouts.length !== tx.inputsLength) throw new SparkInputError("transaction: prevouts must contain one entry per input to account the spend");
497
+ const outputs = [];
498
+ for (let i = 0; i < tx.outputsLength; i++) {
499
+ const output = tx.getOutput(i);
500
+ if (output.script === void 0 || output.amount === void 0) throw new SparkInputError("transaction: output carries no script or amount");
501
+ outputs.push({
502
+ script: output.script,
503
+ amountSats: Number(output.amount)
504
+ });
505
+ }
506
+ return {
507
+ kind: "onchain",
508
+ source,
509
+ outputs,
510
+ inputSats: target.prevouts.reduce((sum, p) => sum + p.amount, 0),
511
+ totalSats: outputs.reduce((sum, o) => sum + o.amountSats, 0)
512
+ };
513
+ }
514
+ /** `token` outflow decoded from a PartialTokenTransaction: owner, identifier, u128 amount. */
515
+ function tokenOutflow(partialTransaction) {
516
+ let partial;
517
+ try {
518
+ partial = fromBinary(PartialTokenTransactionSchema, partialTransaction);
519
+ } catch (error) {
520
+ throw new SparkInputError("token transaction: failed to decode protobuf", { cause: error });
521
+ }
522
+ return {
523
+ kind: "token",
524
+ outputs: partial.partialTokenOutputs.map((output) => ({
525
+ tokenIdentifier: output.tokenIdentifier,
526
+ ownerPublicKey: output.ownerPublicKey,
527
+ amount: bytesToNumberBE(output.tokenAmount)
528
+ }))
529
+ };
530
+ }
531
+ function createOutflowTracker(sspIdentityPublicKey, network, identityPublicKey) {
532
+ const signs = /* @__PURE__ */ new Map();
533
+ const transfers = /* @__PURE__ */ new Map();
534
+ const leafReceivers = /* @__PURE__ */ new Map();
535
+ const receiverScripts = /* @__PURE__ */ new Map();
536
+ const serviceAuthorized = /* @__PURE__ */ new Set();
537
+ const identityScript = p2trScriptFromPublicKey(identityPublicKey, network);
538
+ const evaluateJobs = (jobs) => {
539
+ const exits = /* @__PURE__ */ new Map();
540
+ const lightnings = /* @__PURE__ */ new Map();
541
+ let batchExit;
542
+ const leaves = /* @__PURE__ */ new Map();
543
+ for (const job of jobs) {
544
+ const prevout = job.target.prevouts[job.target.inputIndex];
545
+ if (prevout === void 0) throw new SparkInputError("signFrost: inputIndex out of range for prevouts");
546
+ const tx = parseTransaction(job.target.transaction);
547
+ const key = bytesToHex(job.publicKey);
548
+ if (tx.inputsLength > 1) {
549
+ const foreign = /* @__PURE__ */ new Map();
550
+ for (let i = 0; i < tx.inputsLength; i++) {
551
+ const otherPrevout = job.target.prevouts[i];
552
+ if (otherPrevout === void 0) throw new SparkInputError("signFrost: prevouts must cover every input");
553
+ if (!eq(otherPrevout.script, prevout.script)) {
554
+ const { txid } = tx.getInput(i);
555
+ if (txid === void 0) throw new SparkInputError("signFrost: foreign input carries no prevout txid");
556
+ foreign.set(bytesToHex(txid), txid);
557
+ }
558
+ }
559
+ if (foreign.size > 0) {
560
+ if (foreign.size !== 1) throw new SparkInputError("signFrost: foreign inputs carry more than one connector txid");
561
+ const txid = foreign.values().next().value;
562
+ if (txid === void 0) throw new SparkInputError("signFrost: malformed input set");
563
+ if (job.exit === void 0) throw new SparkInputError("signFrost: connector refund job missing its connector/exit transaction pair");
564
+ const connectorTx = parseTransaction(job.exit.connectorTx);
565
+ if (!eq(txIdInputBytes(job.exit.connectorTx), txid)) throw new SparkInputError("signFrost: connectorTx txid does not match the foreign input");
566
+ const parentTxid = connectorTx.getInput(0).txid;
567
+ if (parentTxid === void 0) throw new SparkInputError("signFrost: connectorTx has no input 0 txid");
568
+ if (!eq(parentTxid, txIdInputBytes(job.exit.exitTx))) throw new SparkInputError("signFrost: connectorTx input 0 does not spend exitTx");
569
+ if (batchExit === void 0) batchExit = job.exit;
570
+ else if (!eq(batchExit.connectorTx, job.exit.connectorTx) || !eq(batchExit.exitTx, job.exit.exitTx)) throw new SparkInputError("signFrost: exit jobs in one batch carry different transaction pairs");
571
+ const entry = leaves.get(key) ?? {
572
+ publicKey: job.publicKey,
573
+ amountSats: 0
574
+ };
575
+ entry.amountSats = Math.max(entry.amountSats, prevout.amount);
576
+ leaves.set(key, entry);
577
+ exits.set(bytesToHex(txid), txid);
578
+ continue;
579
+ }
580
+ }
581
+ const allowedReceivers = leafReceivers.get(key);
582
+ const violations = outputScripts(tx).filter((script) => {
583
+ if (eq(script, EPHEMERAL_ANCHOR_SCRIPT) || eq(script, prevout.script)) return false;
584
+ if (eq(script, identityScript)) return false;
585
+ if ((job.key.type === "leaf" || job.key.type === "static-deposit") && eq(script, p2trScriptFromPublicKey(job.publicKey, network))) return false;
586
+ if (allowedReceivers !== void 0) for (const receiver of allowedReceivers.keys()) {
587
+ const accepted = receiverScripts.get(receiver);
588
+ if (accepted !== void 0 && eq(script, accepted)) return false;
589
+ }
590
+ return true;
591
+ });
592
+ if (violations.length === 0) {
593
+ if (job.exit !== void 0) throw new SparkInputError("signFrost: connector/exit pair on a non-exit job");
594
+ continue;
595
+ }
596
+ if (job.exit !== void 0) throw new SparkInputError("signFrost: connector/exit pair on a non-exit job");
597
+ const entry = leaves.get(key) ?? {
598
+ publicKey: job.publicKey,
599
+ amountSats: 0
600
+ };
601
+ entry.amountSats = Math.max(entry.amountSats, prevout.amount);
602
+ leaves.set(key, entry);
603
+ if (tx.inputsLength === 1) {
604
+ const distinct = /* @__PURE__ */ new Map();
605
+ for (const script of violations) distinct.set(bytesToHex(script), script);
606
+ if (distinct.size !== 1) throw new SparkInputError("signFrost: job carries multiple non-whitelisted outputs");
607
+ const script = distinct.values().next().value;
608
+ if (script === void 0) throw new SparkInputError("signFrost: malformed output set");
609
+ lightnings.set(bytesToHex(script), script);
610
+ } else throw new SparkInputError(`signFrost: non-whitelisted output on a ${tx.inputsLength}-input transaction`);
611
+ }
612
+ if (exits.size > 0 && lightnings.size > 0) throw new SparkInputError("signFrost: batch mixes connector and HTLC refunds");
613
+ const leafList = [...leaves.values()];
614
+ const totalSats = leafList.reduce((sum, l) => sum + l.amountSats, 0);
615
+ if (exits.size > 1 || lightnings.size > 1) throw new SparkInputError("signFrost: inconsistent connector txids or HTLC scripts in one batch");
616
+ const connectorTxid = exits.values().next().value;
617
+ if (connectorTxid !== void 0) {
618
+ if (batchExit === void 0) throw new SparkInputError("signFrost: exit candidates without their transaction pair");
619
+ const exitTx = parseTransaction(batchExit.exitTx);
620
+ const exitOutputs = [];
621
+ for (let i = 0; i < exitTx.outputsLength; i++) {
622
+ const output = exitTx.getOutput(i);
623
+ if (output.script === void 0 || output.amount === void 0) throw new SparkInputError("signFrost: exitTx output carries no script or amount");
624
+ exitOutputs.push({
625
+ script: output.script,
626
+ amountSats: Number(output.amount)
627
+ });
628
+ }
629
+ return {
630
+ kind: "exit",
631
+ connectorTxid,
632
+ leaves: leafList,
633
+ totalSats,
634
+ exitOutputs
635
+ };
636
+ }
637
+ const htlcOutputScript = lightnings.values().next().value;
638
+ if (htlcOutputScript !== void 0) return {
639
+ kind: "lightning",
640
+ htlcOutputScript,
641
+ leaves: leafList,
642
+ totalSats
643
+ };
644
+ };
645
+ const recordSign = (signingPublicKey, target) => {
646
+ const prevout = target.prevouts[target.inputIndex];
647
+ if (prevout === void 0) throw new SparkInputError("signFrost: inputIndex out of range for prevouts");
648
+ const key = bytesToHex(signingPublicKey);
649
+ let record = signs.get(key);
650
+ if (record === void 0) {
651
+ if (signs.size >= MAX_SIGNING_KEYS) {
652
+ const oldest = signs.keys().next().value;
653
+ if (oldest !== void 0) signs.delete(oldest);
654
+ }
655
+ record = { maxAmount: 0 };
656
+ signs.set(key, record);
657
+ }
658
+ if (prevout.amount > record.maxAmount) record.maxAmount = prevout.amount;
659
+ };
660
+ const recordPrepare = (transferId, leaves, operatorPackages) => {
661
+ if (!transfers.has(transferId) && transfers.size >= MAX_TRANSFERS) {
662
+ const oldest = transfers.keys().next().value;
663
+ if (oldest !== void 0) transfers.delete(oldest);
664
+ }
665
+ for (const leaf of leaves) {
666
+ const pk = bytesToHex(leaf.publicKey);
667
+ let receivers = leafReceivers.get(pk);
668
+ if (receivers === void 0) {
669
+ receivers = /* @__PURE__ */ new Map();
670
+ leafReceivers.set(pk, receivers);
671
+ }
672
+ const receiver = bytesToHex(leaf.receiverIdentityPublicKey);
673
+ receivers.set(receiver, (receivers.get(receiver) ?? 0) + 1);
674
+ receiverScripts.set(receiver, p2trScriptFromPublicKey(leaf.receiverIdentityPublicKey, network));
675
+ }
676
+ transfers.set(transferId, {
677
+ leaves: leaves.map((leaf) => ({
678
+ ...leaf,
679
+ key: { ...leaf.key },
680
+ publicKey: new Uint8Array(leaf.publicKey),
681
+ receiverIdentityPublicKey: new Uint8Array(leaf.receiverIdentityPublicKey)
682
+ })),
683
+ operatorPackages: Object.fromEntries(Object.entries(operatorPackages).map(([id, pkg]) => [id, new Uint8Array(pkg)]))
684
+ });
685
+ };
686
+ const packageOutflow = (transferId, packageBytes) => {
687
+ const prepared = transfers.get(transferId);
688
+ if (prepared === void 0) throw new SparkInputError("transfer package: no recorded prepareTransfer for this transferId");
689
+ const keyTweakPackage = (() => {
690
+ try {
691
+ return fromBinary(TransferPackageSchema, packageBytes).keyTweakPackage;
692
+ } catch (error) {
693
+ throw new SparkInputError("transfer package: failed to decode protobuf", { cause: error });
694
+ }
695
+ })();
696
+ const expected = Object.keys(prepared.operatorPackages).sort();
697
+ const actual = Object.keys(keyTweakPackage).sort();
698
+ if (expected.length !== actual.length || !expected.every((id, i) => id === actual[i]) || !expected.every((id) => {
699
+ const value = keyTweakPackage[id];
700
+ const recorded = prepared.operatorPackages[id];
701
+ return value !== void 0 && recorded !== void 0 && eq(recorded, value);
702
+ })) throw new SparkInputError("transfer package: key_tweak_package does not match the recorded operator packages");
703
+ const leafAmounts = prepared.leaves.map((leaf) => {
704
+ const record = signs.get(bytesToHex(leaf.publicKey));
705
+ if (record === void 0) throw new SparkInputError(`transfer package: no recorded refund signature for leaf ${leaf.leafId}`);
706
+ return record.maxAmount;
707
+ });
708
+ const totalSats = leafAmounts.reduce((sum, amount) => sum + amount, 0);
709
+ const sspCount = prepared.leaves.map((leaf) => eq(leaf.receiverIdentityPublicKey, sspIdentityPublicKey)).filter(Boolean).length;
710
+ if (sspCount > 0 && sspCount !== prepared.leaves.length) throw new SparkInputError("transfer package: SSP and non-SSP receivers in one transfer");
711
+ if (sspCount > 0) {
712
+ const authorized = prepared.leaves.filter((leaf) => serviceAuthorized.has(bytesToHex(leaf.publicKey))).length;
713
+ if (authorized === prepared.leaves.length) return;
714
+ if (authorized !== 0) throw new SparkInputError("transfer package: SSP transfer mixes service-authorized and unauthorized leaves");
715
+ return {
716
+ kind: "swap",
717
+ transferId,
718
+ totalSats
719
+ };
720
+ }
721
+ const receivers = /* @__PURE__ */ new Map();
722
+ for (const [i, leaf] of prepared.leaves.entries()) {
723
+ const key = bytesToHex(leaf.receiverIdentityPublicKey);
724
+ const amount = leafAmounts[i];
725
+ if (amount === void 0) throw new SparkInputError("transfer package: leaf amount out of range");
726
+ const entry = receivers.get(key);
727
+ if (entry === void 0) receivers.set(key, {
728
+ identityPublicKey: leaf.receiverIdentityPublicKey,
729
+ amountSats: amount
730
+ });
731
+ else entry.amountSats += amount;
732
+ }
733
+ return {
734
+ kind: "transfer",
735
+ transferId,
736
+ totalSats,
737
+ receivers: [...receivers.values()]
738
+ };
739
+ };
740
+ const drop = (transferId) => {
741
+ const prepared = transfers.get(transferId);
742
+ if (prepared !== void 0) {
743
+ for (const leaf of prepared.leaves) {
744
+ const pk = bytesToHex(leaf.publicKey);
745
+ const receivers = leafReceivers.get(pk);
746
+ const receiver = bytesToHex(leaf.receiverIdentityPublicKey);
747
+ if (receivers !== void 0) {
748
+ const count = receivers.get(receiver);
749
+ if (count !== void 0) {
750
+ if (count > 1) receivers.set(receiver, count - 1);
751
+ else {
752
+ receivers.delete(receiver);
753
+ if (receivers.size === 0) leafReceivers.delete(pk);
754
+ }
755
+ }
756
+ }
757
+ }
758
+ transfers.delete(transferId);
759
+ }
760
+ };
761
+ return {
762
+ evaluateJobs,
763
+ markServiceAuthorized: (publicKeys) => {
764
+ for (const pk of publicKeys) serviceAuthorized.add(bytesToHex(pk));
765
+ },
766
+ recordSign,
767
+ recordPrepare,
768
+ packageOutflow,
769
+ drop,
770
+ wipe: () => {
771
+ signs.clear();
772
+ transfers.clear();
773
+ leafReceivers.clear();
774
+ serviceAuthorized.clear();
775
+ receiverScripts.clear();
776
+ }
777
+ };
778
+ }
779
+ //#endregion
437
780
  //#region src/signer/statements.ts
438
781
  /**
439
782
  * Computes the digest for a statement from its complete protocol content, plus the signature format
@@ -757,6 +1100,17 @@ function createSparkSigner(options) {
757
1100
  }
758
1101
  const keys = openKeys(options.accountKey, crypto);
759
1102
  const nonces = createNonceStore(crypto, keys.accountPrivate, random);
1103
+ const outflow = createOutflowTracker(sspIdentityPublicKey, network, keys.identityPublicKey);
1104
+ const { authorize } = options;
1105
+ /** The signing public key for any key descriptor — resolved here, private copy wiped. */
1106
+ const publicKeyOf = (key) => {
1107
+ const secretKey = keys.resolve(key);
1108
+ try {
1109
+ return secp256k1.getPublicKey(secretKey, true);
1110
+ } finally {
1111
+ secretKey.fill(0);
1112
+ }
1113
+ };
760
1114
  const statements = {
761
1115
  identityPoint: keys.identityPoint,
762
1116
  identityPublicKey: keys.identityPublicKey,
@@ -805,7 +1159,26 @@ function createSparkSigner(options) {
805
1159
  ensureAlive();
806
1160
  await ready;
807
1161
  ensureAlive();
808
- return frostSignJobs(crypto, jobs, keys, nonces);
1162
+ const publicKeys = jobs.map((job) => publicKeyOf(job.key));
1163
+ const batchOutflow = outflow.evaluateJobs(jobs.map((job, i) => {
1164
+ const publicKey = publicKeys[i];
1165
+ if (publicKey === void 0) throw new SparkError("signFrost: public key resolution out of range");
1166
+ return {
1167
+ key: job.key,
1168
+ publicKey,
1169
+ target: job.target,
1170
+ ...job.exit === void 0 ? {} : { exit: job.exit }
1171
+ };
1172
+ }));
1173
+ if (batchOutflow !== void 0) await authorize?.(batchOutflow);
1174
+ const shares = frostSignJobs(crypto, jobs, keys, nonces);
1175
+ for (const [i, job] of jobs.entries()) {
1176
+ const publicKey = publicKeys[i];
1177
+ if (publicKey === void 0) throw new SparkError("signFrost: public key resolution out of range");
1178
+ outflow.recordSign(publicKey, job.target);
1179
+ }
1180
+ if (batchOutflow !== void 0) outflow.markServiceAuthorized(batchOutflow.leaves.map((leaf) => leaf.publicKey));
1181
+ return shares;
809
1182
  };
810
1183
  const aggregateFrost = async (jobs) => {
811
1184
  ensureAlive();
@@ -817,13 +1190,24 @@ function createSparkSigner(options) {
817
1190
  ensureAlive();
818
1191
  await ready;
819
1192
  ensureAlive();
1193
+ if (statement.kind === "transfer-package") {
1194
+ const transferOutflow = outflow.packageOutflow(statement.transferId, statement.package);
1195
+ if (transferOutflow !== void 0) await authorize?.(transferOutflow);
1196
+ const { digest, format } = statementDigest(statement, statements);
1197
+ const signature = signDigest(digest, format, keys.identityPrivateKey, random);
1198
+ outflow.drop(statement.transferId);
1199
+ return signature;
1200
+ }
820
1201
  const { digest, format } = statementDigest(statement, statements);
1202
+ if (statement.kind === "static-deposit-refund" || statement.kind === "watchtower-recovery") await authorize?.(onchainOutflow(statement.kind, statement.target));
1203
+ else if (statement.kind === "token-transaction") await authorize?.(tokenOutflow(statement.partialTransaction));
821
1204
  return signDigest(digest, format, keys.identityPrivateKey, random);
822
1205
  };
823
1206
  const signKeySpend = async (key, target) => {
824
1207
  ensureAlive();
825
1208
  await ready;
826
1209
  ensureAlive();
1210
+ await authorize?.(onchainOutflow("key-spend", target));
827
1211
  const secretKey = keys.resolve(key);
828
1212
  let tweaked;
829
1213
  try {
@@ -838,6 +1222,14 @@ function createSparkSigner(options) {
838
1222
  ensureAlive();
839
1223
  await ready;
840
1224
  ensureAlive();
1225
+ await authorize?.(onchainOutflow("htlc-timeout", {
1226
+ transaction: request.transaction,
1227
+ inputIndex: request.inputIndex,
1228
+ prevouts: [{
1229
+ script: lightningHtlcOutputScript(request.paymentHash, request.hashLockPublicKey, keys.identityPublicKey),
1230
+ amount: request.prevoutAmountSats
1231
+ }]
1232
+ }));
841
1233
  const tx = parseTransaction(request.transaction);
842
1234
  if (tx.inputsLength !== 1 || request.inputIndex !== 0) throw new SparkInputError("htlc timeout: only the single-input sender sweep is supported");
843
1235
  const { sequence } = tx.getInput(0);
@@ -858,7 +1250,17 @@ function createSparkSigner(options) {
858
1250
  ensureAlive();
859
1251
  await ready;
860
1252
  ensureAlive();
861
- return prepareTransferPackage(request, packages);
1253
+ const prepared = prepareTransferPackage(request, packages);
1254
+ const leafPublicKeys = keys.publicKeys(request.leaves.map((leaf) => leaf.key));
1255
+ outflow.recordPrepare(request.transferId, request.leaves.map((leaf, i) => {
1256
+ const publicKey = leafPublicKeys[i];
1257
+ if (publicKey === void 0) throw new SparkError("prepareTransfer: public key resolution out of range");
1258
+ return {
1259
+ ...leaf,
1260
+ publicKey
1261
+ };
1262
+ }), prepared.operatorPackages);
1263
+ return prepared;
862
1264
  };
863
1265
  const prepareClaim = async (request) => {
864
1266
  ensureAlive();
@@ -883,6 +1285,7 @@ function createSparkSigner(options) {
883
1285
  disposed = true;
884
1286
  nonces.wipe();
885
1287
  keys.wipe();
1288
+ outflow.wipe();
886
1289
  };
887
1290
  return {
888
1291
  identityPublicKey,
@@ -1021,4 +1424,4 @@ function createLnurlAuthSigner(options) {
1021
1424
  };
1022
1425
  }
1023
1426
  //#endregion
1024
- export { createLnurlAuthSigner, createSparkSigner, lnurlAuthRootKey, sparkAccountKey };
1427
+ export { EPHEMERAL_ANCHOR_SCRIPT, addressToScript, createLnurlAuthSigner, createSparkSigner, decodeBolt11, lightningSendHtlcScript, lnurlAuthRootKey, sparkAccountKey, sparkNetworkConfig };
@@ -0,0 +1,9 @@
1
+ //#region src/core/text.ts
2
+ const CONTROL = /[\u0000-\u001F\u007F-\u009F]/g;
3
+ /** Server-supplied error text made safe to embed in an error message. */
4
+ function serverErrorText(text) {
5
+ const cleaned = text.replace(CONTROL, "");
6
+ return cleaned.length <= 256 ? cleaned : `${cleaned.slice(0, 256)}\u2026`;
7
+ }
8
+ //#endregion
9
+ export { serverErrorText as t };
@@ -1,5 +1,6 @@
1
1
  import { n as SparkNetwork, t as LightningAddressStatement } from "./lightning-address-CRULmi9v.mjs";
2
2
  import { Transaction } from "@scure/btc-signer";
3
+ import "@scure/btc-signer/utils.js";
3
4
  //#region src/core/bitcoin/sighash.d.ts
4
5
  /** A transaction to sign: raw bytes plus every input's prevout script and amount. */
5
6
  type SigningTarget = {
@@ -62,6 +63,16 @@ type FrostSignJob = {
62
63
  readonly operatorCommitments: ReadonlyArray<OperatorCommitment>;
63
64
  /** Optional 33-byte adaptor point; omit or leave empty for plain signatures. */
64
65
  readonly adaptorPublicKey?: Uint8Array;
66
+ /**
67
+ * For connector (cooperative-exit) refunds only: the raw connector transaction plus the
68
+ * SSP-provided coop-exit payout transaction it spends — binds the signed refund shares to a
69
+ * disclosed exit destination. Required on jobs spending a foreign connector input; rejected on
70
+ * any other job.
71
+ */
72
+ readonly exit?: {
73
+ readonly connectorTx: Uint8Array;
74
+ readonly exitTx: Uint8Array;
75
+ };
65
76
  };
66
77
  /** Aggregation of the user's share (obtained from {@link SparkSigner.signFrost}) with the SOs'. */
67
78
  type FrostAggregateJob = {
@@ -319,4 +330,16 @@ type SparkSigner = {
319
330
  readonly dispose: () => void;
320
331
  };
321
332
  //#endregion
322
- export { StaticDepositClaimRequest as _, OperatorCommitment as a, PreparedClaim as c, PreparedTransferLeaf as d, SigningCommitment as f, SparkSigner as g, SparkOperator as h, IdentityStatement as i, PreparedLightningReceive as l, SparkLeafKey as m, FrostSignJob as n, PrepareClaimRequest as o, SparkKey as p, HtlcTimeoutSignRequest as r, PrepareTransferRequest as s, FrostAggregateJob as t, PreparedTransfer as u, SigningTarget as v };
333
+ //#region src/core/bitcoin/script.d.ts
334
+ /** Ephemeral anchor (P2A): OP_TRUE, push 2 bytes, 0x4e73 — value 0 sats. */
335
+ declare const EPHEMERAL_ANCHOR_SCRIPT: Uint8Array;
336
+ //#endregion
337
+ //#region src/core/bitcoin/transactions.d.ts
338
+ /**
339
+ * Decodes a Bitcoin address to its output script under any network — matching upstream
340
+ * `Address::from_str(..).assume_checked()`, which never checks that the address's network matches
341
+ * the operating network.
342
+ */
343
+ declare function addressToScript(address: string): Uint8Array;
344
+ //#endregion
345
+ export { SparkOperator as _, HtlcTimeoutSignRequest as a, SigningTarget as b, PrepareClaimRequest as c, PreparedLightningReceive as d, PreparedTransfer as f, SparkLeafKey as g, SparkKey as h, FrostSignJob as i, PrepareTransferRequest as l, SigningCommitment as m, EPHEMERAL_ANCHOR_SCRIPT as n, IdentityStatement as o, PreparedTransferLeaf as p, FrostAggregateJob as r, OperatorCommitment as s, addressToScript as t, PreparedClaim as u, SparkSigner as v, StaticDepositClaimRequest as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qntx/spark",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Spark Bitcoin L2 protocol for TypeScript: platform-neutral client, signer and crypto-backend interfaces.",
5
5
  "keywords": [
6
6
  "bitcoin",
@@ -85,7 +85,7 @@
85
85
  "publint": "0.3.25",
86
86
  "typescript": "7.0.2",
87
87
  "undici": "8.12.2",
88
- "vite-plus": "1.2.0"
88
+ "vite-plus": "1.3.0"
89
89
  },
90
90
  "devEngines": {
91
91
  "packageManager": {