@arkade-os/swap 0.0.4 → 0.0.5

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
@@ -436,34 +436,42 @@ descriptor, which is public, and `contractSigner(wallet, descriptor)` recovers t
436
436
 
437
437
  What each swap stores, and what is recoverable:
438
438
 
439
- | Wallet answers with | Spending key | Preimage (when the leg needs one) | Secret at rest |
440
- | ------------------- | --------------------- | --------------------------------- | ----------------- |
441
- | fresh HD descriptor | re-derives from seed | derives deterministically | none |
442
- | static `tr(pubkey)` | the wallet's identity | random, stored on the record | the preimage only |
443
-
444
- The preimage split follows the **descriptor's shape**, not the wallet's type: an HD child
445
- descriptor is unique to its swap, so `sha256(sign_det(...))` is safe; a static descriptor is the
446
- same key for every swap, so a derived preimage would repeat across swaps — one solver learning its
447
- own preimage would learn every other swap's and a per-swap random preimage is stored instead.
448
- `mustPersistPreimage` says which you got. A stored preimage is the one secret at rest in the
449
- design, and it is never a private key.
439
+ | Wallet answers with | Spending key | Preimage (when the leg needs one) | Secret at rest |
440
+ | ------------------------- | --------------------- | ------------------------------------- | ----------------- |
441
+ | fresh HD descriptor | re-derives from seed | derives deterministically | none |
442
+ | static `tr(pubkey)` | the wallet's identity | derives from a public per-swap salt | none |
443
+ | a signer that cannot sign | | | |
444
+ | deterministically | the wallet's identity | random, stored on the record | the preimage only |
445
+
446
+ The preimage split follows the **descriptor's shape**, not the wallet's type. An HD child
447
+ descriptor is unique to its swap, so `sha256(sign_det(...))` over the key alone is safe. A static
448
+ descriptor is the same key for every swap, so that derivation would repeat across swaps — one
449
+ solver learning its own preimage would learn every other swap's — and the uniqueness has to come
450
+ from the message instead: the SDK mints 32 random bytes per swap, signs a **salted** message, and
451
+ stores the salt in the clear.
452
+
453
+ **The salt is not a secret.** Knowing it yields nothing without the seed, which is the whole
454
+ difference from the preimage it replaces: the record goes from carrying a per-swap _secret_ to a
455
+ per-swap _public_ value, exactly what `signingDescriptor` already is. Recoverability is unchanged
456
+ in shape — keep the record and the swap recovers from the seed.
457
+
458
+ Only a signer that cannot sign deterministically at all — an external or extension signer — still
459
+ gets a random stored preimage. `mustPersistPreimage` says which you got, and it is the only thing
460
+ to branch on. A stored preimage remains the one secret at rest in the design, and it is never a
461
+ private key.
450
462
 
451
463
  ```ts
452
464
  const swap = await requestOnchainSend(/* … */);
453
- // `swapSecretsToRecord` stores the public descriptor always, and `preimageHex`
454
- // only when the wallet said it cannot re-derive P.
465
+ // `swapSecretsToRecord` stores the public descriptor always, then whichever of
466
+ // `preimageSaltHex` (derivable) or `preimageHex` (not) the wallet produced.
455
467
  await saveSwap({ ...record, ...swapSecretsToRecord(swap.secrets) });
456
468
 
457
- // Later, from the seed plus that descriptor. Only ask for a preimage the
458
- // corridor gave us one for: a lightning send's P belongs to the payee, so
459
- // this throws on those records rather than inventing something the chain will
460
- // never match. `LIGHTNING_SEND_PAIR` is exported from this package.
461
- if (record.signingDescriptor && record.pair !== LIGHTNING_SEND_PAIR) {
462
- const preimage = await contractPreimage(
463
- wallet,
464
- record.signingDescriptor,
465
- record.preimageHex ? hex.decode(record.preimageHex) : undefined,
466
- );
469
+ // Later, from the seed plus the record's public fields. Only ask for a
470
+ // preimage the corridor gave us one for: a lightning send's P belongs to the
471
+ // payee, so this throws on those records rather than inventing something the
472
+ // chain will never match. `LIGHTNING_SEND_PAIR` is exported from this package.
473
+ if (record.pair !== LIGHTNING_SEND_PAIR) {
474
+ const preimage = await preimageForSwapRecord(wallet, record);
467
475
  }
468
476
 
469
477
  // For a refund, take the composition instead of the guard: it turns all three
@@ -481,21 +489,45 @@ terminal: the lockup stays funded and watched, a solver claim still ends the swa
481
489
  `pending`. The manager reports the same state when nothing is wired to act (`enableAutoActions:
482
490
  false`, or no callbacks) and the window has passed.
483
491
 
492
+ `preimageForSwapRecord` is the read path to wire, not a hand-rolled `contractPreimage` call: it
493
+ knows which of the record's fields are derivation inputs, and it verifies the result against
494
+ `paymentHash`. A caller that forgets to pass the salt gets a _wrong_ preimage from a wallet that can
495
+ derive, not an error — and that surfaces as an opaque script failure at claim time.
496
+
497
+ Every refusal is a `PreimageNotRecoverableError` carrying a `reason`: `no-secrets` (the record
498
+ predates the descriptor), `malformed-record`, `not-derivable` (nothing to derive from, or a key this
499
+ wallet does not hold), or `hash-mismatch` (derived, but wrong — a tampered salt or the wrong seed).
500
+ Branch on `reason`, never on message text. It is deliberately **not**
501
+ `RefundNotLocallyPossibleError`: that one means no local refund is possible and `RfqSwapManager`
502
+ reports `needs_counterparty` for it, which is a different verdict from a claim-path read failing.
503
+
484
504
  A caller-supplied preimage keeps `signingDescriptor` for the sender key and stores only
485
505
  `preimageHex` as secret material.
486
506
 
487
507
  On an HD wallet each swap **allocates** its own descriptor rather than peeking at the current one:
488
508
  two swaps sharing a descriptor derive the _identical_ preimage, so one solver learning its own
489
- preimage would learn the other swap's. (Static wallets share their one descriptor by design — that
490
- is why their preimages are stored per swap, never derived.) On restore, `adoptContractDescriptor`
509
+ preimage would learn the other swap's. (Static wallets share their one descriptor by design — the
510
+ per-swap salt is what separates their preimages instead.) On restore, `adoptContractDescriptor`
491
511
  (from `@arkade-os/sdk`) moves the wallet's watermark past a restored record's index so it cannot be
492
512
  handed out twice; a static descriptor names no index and adopts as a no-op.
493
513
 
494
- The derivation is `sha256(signSchnorrDeterministic(sha256("Arkade-RFQ-Preimage-v1" xonly(32)
495
- u32le(0))))`, mirroring NArk's Boltz scheme (`SwapsManagementService.cs:128-160`) with an
496
- RFQ-scoped tag. NArk has no RFQ corridor yet, so this tag defines the scheme rather than matching
497
- one; it is deliberately distinct from the Boltz tag so one wallet key cannot derive the same
498
- preimage for both corridors.
514
+ Two derivations, picked by the descriptor's shape:
515
+
516
+ ```
517
+ HD child sha256(sign_det(sha256("Arkade-RFQ-Preimage-v1" ‖ xonly(32) u32le(0))))
518
+ static/salted sha256(sign_det(sha256("Arkade-Contract-Preimage-Salted-v1" xonly(32) ‖ salt(32))))
519
+ ```
520
+
521
+ The first mirrors NArk's Boltz scheme (`SwapsManagementService.cs:128-160`) with an RFQ-scoped tag.
522
+ NArk has no RFQ corridor yet, so this tag defines the scheme rather than matching one; it is
523
+ deliberately distinct from the Boltz tag so one wallet key cannot derive the same preimage for both
524
+ corridors.
525
+
526
+ The salted tag is corridor-generic where the first is not, and that asymmetry is deliberate: the v1
527
+ tags must be per-corridor because v1 pins its message index, leaving the tag as the only separation
528
+ between two corridors reaching the same key. The salted form mints a fresh salt per swap, so no two
529
+ swaps share a message within a corridor or across two — the salt carries the separation, and the tag
530
+ names the layer rather than the corridor.
499
531
 
500
532
  **Not covered:** seed-only discovery after the swap repository is wiped. An unspent L1 HTLC reveals
501
533
  too little public quote data to rediscover, so the record remains required.
@@ -508,9 +540,71 @@ before later-funded addresses are found. Keep the swap repository in backups (re
508
540
  each record's descriptor via `adoptContractDescriptor`), or raise `gapLimit` on seed-only restores
509
541
  after heavy swap use.
510
542
 
543
+ ## Upgrading from 0.0.3
544
+
545
+ 0.0.1–0.0.3 are published. Under npm's 0.0.x rules `^0.0.3` resolves to exactly 0.0.3, so nothing
546
+ auto-upgrades into the changes below — but a consumer that does upgrade meets them all in one jump,
547
+ so they are written as one migration rather than per-release fragments.
548
+
549
+ **Key provisioning moved into the SDK.** `packages/swap/src/secrets.ts` is gone. `deriveSwapSecrets`,
550
+ `randomSwapSecrets`, `preimageForRfqSecrets`, `senderIdentityForRfqSecrets`, `rfqSecretsToRecord`,
551
+ `rfqSecretsOfRecord`, `isPerSwapDescriptor`, `RFQ_PREIMAGE_TAG` and `SwapSecrets` no longer exist.
552
+ Import `provisionRefundKey`, `provisionClaimSecret`, `contractSigner`, `contractPreimage`,
553
+ `isPerArtifactDescriptor` and `ARKADE_SWAP_PREIMAGE_TAG` from `@arkade-os/sdk` instead;
554
+ `swapSecretsToRecord` and `senderIdentityForSwapRecord` stay in this package. No consumer branches
555
+ on wallet type any more, and no swap record can carry a private key.
556
+
557
+ **`contractPreimage` takes an options object.** `contractPreimage(wallet, descriptor, stored?)`
558
+ became `contractPreimage(wallet, descriptor, { stored?, salt? })`. Prefer `preimageForSwapRecord`,
559
+ which reads both fields off the record and verifies against `paymentHash`.
560
+
561
+ **Static wallets derive their preimage instead of storing it.** New records from such wallets carry
562
+ `preimageSaltHex` and no `preimageHex`; `mustPersistPreimage` is now `false` for them, so the
563
+ "persist the preimage" warning stops firing. Nothing at rest is secret unless the signer cannot sign
564
+ deterministically at all.
565
+
566
+ **`AssetSwap` gains `preimageSaltHex?`, and `AssetSwapRepository.version` is `2`.** External
567
+ repository implementations must recompile — deliberately, because a field-mapped backend that drops
568
+ `preimageSaltHex` leaves the swap unclaimable exactly as one dropping `preimageHex` does. Records
569
+ written by 0.0.1–0.0.3 need no rewrite and no migration: the field is optional, older rows resolve
570
+ through their stored `preimageHex` or their HD descriptor, and `DB_VERSION` is unchanged.
571
+
511
572
  ## Breaking changes on this branch (pre-release migration notes)
512
573
 
513
- The package is pre-release; these notes replace a changelog for consumers tracking the branch.
574
+ Notes from before 0.0.1, kept for consumers who tracked the branch.
575
+
576
+ - **Every derived address changed again, in both corridors — the unilateral ladder was re-spaced.**
577
+ `unilateralRefundDelay` now sits **level with** `claimDelay` instead of one 512s step above it,
578
+ and `unilateralRefundWithoutReceiverDelay` sits `SOLO_REFUND_HEADROOM_SECONDS` (4096s, newly
579
+ exported) above it instead of two steps. The old ladder spaced all three leaves one step apart as
580
+ though they were interchangeable rungs; they are not. Only `unilateralRefundWithoutReceiver` is a
581
+ solo path for the funder, so it is the only one whose timing can steal, and one 512s tick was
582
+ never enough for a claimant to complete a unilateral exit in. The two-signature refund needs no
583
+ separation at all, since neither party can spend that leaf alone. This tracks the reference
584
+ solver's [lightning-swap-service#81](https://github.com/arkade-os/lightning-swap-service/pull/81);
585
+ the two derivations must produce **the same three delay values** for the same operator, which is
586
+ what keeps the derived addresses identical. **Deployment must be coordinated** on the same terms
587
+ as the entry below: for a quote not yet funded, a mismatch refuses it at `verifyLockupAddress`
588
+ rather than losing funds.
589
+
590
+ **An in-flight lockup funded before the upgrade needs care, and the entry below understates
591
+ this.** The delays are not quote fields and are not persisted on the swap record
592
+ (`AssetSwap` keeps `swapPkScript`, not `claimDelay`), and `RfqSwap`'s own doc tells callers to
593
+ *rebuild* the script on restart from the quote's binding fields — which re-derives the delays
594
+ under whatever ladder is compiled in. So a trader who funded on `0.0.4`, upgraded, and restarted
595
+ rebuilds a **new** address, and `refundIfUnresolved` finds no VTXOs there and returns
596
+ `nothing_to_refund` — a terminal-sounding answer for money still locked at the old script, with
597
+ `refundLocktime` still ticking. Until the delays are persisted and rebuilt from the stored value,
598
+ drain in-flight lockups before upgrading, or rebuild the old script from the pre-upgrade delays
599
+ by hand. This is a pre-existing gap that any address-moving change hits, not one this change
600
+ introduces.
601
+
602
+ `unilateralClaimDelay`'s BIP68 ceiling tightened to reserve the full headroom rather than two
603
+ steps. Note this guard alone is **not** mirrored in the reference solver, which still rejects
604
+ only above `0xffff * 512`: for an operator `unilateralExitDelay` in `(33549824, 33553920]`
605
+ seconds the trader throws here while the solver quotes and then fails deeper in its own script
606
+ build. Both refuse, at different seams with different messages, so it is a diagnosability wart
607
+ rather than a fund risk — and the window is unreachable in practice (~388 days).
514
608
 
515
609
  - **`secrets.ts` is gone; key provisioning moved into `@arkade-os/sdk`.** This package no longer
516
610
  derives, mints, or names keys. It asks the SDK for what the leg needs — `provisionRefundKey(wallet)`
@@ -546,21 +546,22 @@ var relayTransport = (relayUrl, options) => {
546
546
  };
547
547
  };
548
548
  var SEQUENCE_GRANULARITY_SECONDS = 512;
549
+ var SOLO_REFUND_HEADROOM_SECONDS = 8 * SEQUENCE_GRANULARITY_SECONDS;
549
550
  var unilateralClaimDelay = (serverExitDelaySeconds) => {
550
551
  if (!Number.isFinite(serverExitDelaySeconds) || serverExitDelaySeconds < SEQUENCE_GRANULARITY_SECONDS) {
551
552
  throw new Error(
552
553
  `server exit delay must be at least ${SEQUENCE_GRANULARITY_SECONDS}s of seconds, got ${serverExitDelaySeconds}`
553
554
  );
554
555
  }
555
- if (serverExitDelaySeconds > (65535 - 2) * SEQUENCE_GRANULARITY_SECONDS) {
556
+ if (serverExitDelaySeconds > 65535 * SEQUENCE_GRANULARITY_SECONDS - SOLO_REFUND_HEADROOM_SECONDS) {
556
557
  throw new Error(
557
- `server exit delay ${serverExitDelaySeconds}s exceeds what BIP68 can encode once the two refund tiers are stacked above it`
558
+ `server exit delay ${serverExitDelaySeconds}s exceeds what BIP68 can encode once the solo refund's headroom is stacked above it`
558
559
  );
559
560
  }
560
561
  return Math.ceil(serverExitDelaySeconds / SEQUENCE_GRANULARITY_SECONDS) * SEQUENCE_GRANULARITY_SECONDS;
561
562
  };
562
- var unilateralRefundDelay = (claimDelay) => claimDelay + SEQUENCE_GRANULARITY_SECONDS;
563
- var unilateralRefundWithoutReceiverDelay = (claimDelay) => claimDelay + 2 * SEQUENCE_GRANULARITY_SECONDS;
563
+ var unilateralRefundDelay = (claimDelay) => claimDelay;
564
+ var unilateralRefundWithoutReceiverDelay = (claimDelay) => claimDelay + SOLO_REFUND_HEADROOM_SECONDS;
564
565
  function lightningSendVtxoScript(params) {
565
566
  const seconds = (value) => ({
566
567
  type: "seconds",
@@ -1169,6 +1170,7 @@ export {
1169
1170
  assertFundable,
1170
1171
  httpTransport,
1171
1172
  relayTransport,
1173
+ SOLO_REFUND_HEADROOM_SECONDS,
1172
1174
  unilateralClaimDelay,
1173
1175
  unilateralRefundDelay,
1174
1176
  unilateralRefundWithoutReceiverDelay,