@arkade-os/swap 0.0.3 → 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
@@ -303,8 +303,7 @@ import {
303
303
  awaitOnchainFill,
304
304
  claimOnchainFill,
305
305
  addAssetSwap,
306
- preimageForRfqSecrets,
307
- rfqSecretsToRecord,
306
+ swapSecretsToRecord,
308
307
  } from "@arkade-os/swap";
309
308
 
310
309
  const swap = await requestOnchainSend(wallet, arkServerUrl, httpTransport(solverUrl), {
@@ -323,7 +322,7 @@ await addAssetSwap(repository, {
323
322
  swapPkScript: hex.encode(swap.swapPkScript),
324
323
  htlcPkScriptHex: hex.encode(swap.htlc.pkScript),
325
324
  htlcLocktime: swap.htlc.refundLocktime,
326
- ...rfqSecretsToRecord(swap.secrets),
325
+ ...swapSecretsToRecord(swap.secrets),
327
326
  });
328
327
  await wallet.send({ address: swap.address, amount: swap.fundAmount });
329
328
 
@@ -333,7 +332,7 @@ const utxo = await awaitOnchainFill(chain, swap.htlc, minConfirmations);
333
332
  await claimOnchainFill(chain, {
334
333
  htlc: swap.htlc,
335
334
  utxo,
336
- preimage: await preimageForRfqSecrets(wallet, swap.secrets),
335
+ preimage: swap.secrets.preimage,
337
336
  payoutPkScript,
338
337
  feeRateSatVb,
339
338
  sign,
@@ -355,11 +354,9 @@ Crash recovery is record-driven, not chain-driven: `classifyOnchainHtlc` re-deri
355
354
  state (unfunded / awaiting confirmations / claimable / refundable / claimed-with-P / swept) from
356
355
  `ChainSource` plus the stored outpoint — without the stored record a spent HTLC is
357
356
  indistinguishable from an unfunded one, which is why persisting before funding is mandatory. The
358
- `AssetSwap` record carries the onchain fields (`paymentHash`, `signingDescriptor`,
359
- `preimageHex` for caller-supplied P, `fallbackSecrets`, `htlcPkScriptHex`, `htlcLocktime`,
360
- `l1Txid`) and the statuses `awaiting_fill / claimable / claimed / refunded_l1`.
361
- `fallbackSecrets` is versioned and discriminated: `{ version: 1, type: "stored",
362
- senderPrivateKeyHex, preimageHex? }`.
357
+ `AssetSwap` record carries the onchain fields (`paymentHash`, `signingDescriptor`, `preimageHex`
358
+ for a P that cannot be re-derived, `htlcPkScriptHex`, `htlcLocktime`, `l1Txid`) and the statuses
359
+ `awaiting_fill / claimable / claimed / refunded_l1`.
363
360
 
364
361
  **On-board corridors are covered.** `requestLightningReceive` (`lightning:BTC -> arkade:BTC`) and
365
362
  `requestOnchainReceive` (`onchain:BTC -> arkade:BTC`) mirror the send-side flows: quote → derive
@@ -421,29 +418,67 @@ Lightning HTLC lapses, and **the payer is refunded** — the trader loses the in
421
418
  funds it was holding. Which is why staying online to claim is an obligation and not a preference:
422
419
  covclaimd cannot claim this covenant today, so the claim packet's offline path does not yet run.
423
420
 
424
- ## RFQ secrets are derived, not stored
421
+ ## Swap secrets come from the wallet, not from this package
425
422
 
426
- The two secrets an RFQ swap needs the VHTLC `sender` key and, for an onchain send, the preimage
427
- are functions of the wallet seed plus one HD-allocated descriptor. The record keeps the descriptor,
428
- which is public, so a copied browser profile or a device backup yields nothing spendable.
423
+ This package holds no key logic at all. It names the leg it is building and the SDK answers:
424
+
425
+ ```ts
426
+ // a leg we fund — all it needs is the key that refunds it
427
+ const { pubkey: refundPubkey, descriptor: refundDescriptor } = await provisionRefundKey(wallet);
428
+ // a leg we claim — the key that receives it, and the P that unlocks it
429
+ const { pubkey, descriptor, preimage, paymentHash, mustPersistPreimage } =
430
+ await provisionClaimSecret(wallet);
431
+ ```
432
+
433
+ Where the key comes from is the wallet's decision, invisible here: an HD wallet allocates a fresh
434
+ descriptor per swap, a static wallet answers with its one `tr(pubkey)`. The record keeps the
435
+ descriptor, which is public, and `contractSigner(wallet, descriptor)` recovers the signer.
436
+
437
+ What each swap stores, and what is recoverable:
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 | 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.
429
462
 
430
463
  ```ts
431
464
  const swap = await requestOnchainSend(/* … */);
432
- swap.secrets; // { derivable: true, signingDescriptor } — persist it, it holds no secret
433
- await saveSwap({ ...record, ...rfqSecretsToRecord(swap.secrets) });
434
-
435
- // Later, from the seed plus that descriptor. Guard the lookup: offer-corridor
436
- // records (and records that lost their secrets fields) carry no secrets at
437
- // all, and a `!` here would crash the whole recovery loop on the first one.
438
- const secrets = rfqSecretsOfRecord(record);
439
- if (secrets) {
440
- const preimage = await preimageForRfqSecrets(wallet, secrets);
465
+ // `swapSecretsToRecord` stores the public descriptor always, then whichever of
466
+ // `preimageSaltHex` (derivable) or `preimageHex` (not) the wallet produced.
467
+ await saveSwap({ ...record, ...swapSecretsToRecord(swap.secrets) });
468
+
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);
441
475
  }
442
476
 
443
477
  // For a refund, take the composition instead of the guard: it turns all three
444
- // ways a wallet can fail to produce the sender key — no secrets on the record,
445
- // an unreadable fallback arm, a descriptor from another seed into one typed
446
- // `RefundNotLocallyPossibleError` carrying which. Wire `refundArkade` to this.
478
+ // ways a wallet can fail to produce the sender key — the record names no
479
+ // descriptor, the descriptor is another seed's, the wallet holds the key but
480
+ // cannot sign into one typed `RefundNotLocallyPossibleError` carrying which,
481
+ // and lets a signer outage stay retryable. Wire `refundArkade` to this.
447
482
  const sender = await senderIdentityForSwapRecord(wallet, record);
448
483
  ```
449
484
 
@@ -454,23 +489,45 @@ terminal: the lockup stays funded and watched, a solver claim still ends the swa
454
489
  `pending`. The manager reports the same state when nothing is wired to act (`enableAutoActions:
455
490
  false`, or no callbacks) and the window has passed.
456
491
 
457
- `derivable: false` is the fallback for wallets that cannot allocate (static / `auto` / custom
458
- signers). It carries the raw `senderPrivateKey` and, for onchain sends, `preimage`;
459
- `rfqSecretsToRecord` stores them under `AssetSwap.fallbackSecrets` as a complete versioned
460
- record. The discriminant is a type-level fact, so a consumer written against the derivable arm
461
- alone will not compile against the fallback. A caller-supplied preimage on an HD wallet keeps
462
- `signingDescriptor` for the sender key and stores only `preimageHex` as secret material.
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.
463
496
 
464
- Each swap **allocates** its own descriptor rather than peeking at the current one: two swaps sharing
465
- a descriptor derive the _identical_ preimage, so one solver learning its own preimage would learn the
466
- other swap's. On restore, `adoptSwapDescriptor` moves the wallet's watermark past a restored record's
467
- index so it cannot be handed out twice.
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.
468
503
 
469
- The derivation is `sha256(signSchnorrDeterministic(sha256("Arkade-RFQ-Preimage-v1" xonly(32)
470
- u32le(0))))`, mirroring NArk's Boltz scheme (`SwapsManagementService.cs:128-160`) with an
471
- RFQ-scoped tag. NArk has no RFQ corridor yet, so this tag defines the scheme rather than matching
472
- one; it is deliberately distinct from the Boltz tag so one wallet key cannot derive the same
473
- preimage for both corridors.
504
+ A caller-supplied preimage keeps `signingDescriptor` for the sender key and stores only
505
+ `preimageHex` as secret material.
506
+
507
+ On an HD wallet each swap **allocates** its own descriptor rather than peeking at the current one:
508
+ two swaps sharing a descriptor derive the _identical_ preimage, so one solver learning its own
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`
511
+ (from `@arkade-os/sdk`) moves the wallet's watermark past a restored record's index so it cannot be
512
+ handed out twice; a static descriptor names no index and adopts as a no-op.
513
+
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.
474
531
 
475
532
  **Not covered:** seed-only discovery after the swap repository is wiped. An unspent L1 HTLC reveals
476
533
  too little public quote data to rediscover, so the record remains required.
@@ -480,24 +537,102 @@ index from the wallet's receive stream, and a swap index never becomes a funded
480
537
  so it looks _unused_ to a seed-only `restore()` gap scan. Many consecutive swap allocations between
481
538
  two funded receive indices can therefore exceed the scan's `gapLimit` (default 20) and stop it
482
539
  before later-funded addresses are found. Keep the swap repository in backups (restore then adopts
483
- each record's descriptor via `adoptSwapDescriptor`), or raise `gapLimit` on seed-only restores
540
+ each record's descriptor via `adoptContractDescriptor`), or raise `gapLimit` on seed-only restores
484
541
  after heavy swap use.
485
542
 
486
- ## Breaking changes on this branch (pre-release migration notes)
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.
487
548
 
488
- The package is pre-release; these notes replace a changelog for consumers tracking the branch.
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
+
572
+ ## Breaking changes on this branch (pre-release migration notes)
489
573
 
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).
608
+
609
+ - **`secrets.ts` is gone; key provisioning moved into `@arkade-os/sdk`.** This package no longer
610
+ derives, mints, or names keys. It asks the SDK for what the leg needs — `provisionRefundKey(wallet)`
611
+ for a leg it funds, `provisionClaimSecret(wallet, { preimage? })` for one it claims — and
612
+ recovers with `contractSigner(wallet, descriptor)` / `contractPreimage(wallet, descriptor,
613
+ stored?)`. The returned `ProvisionedKey` / `ProvisionedClaimSecret` replace `SwapSecrets`, and
614
+ `descriptor` replaces `signingDescriptor` on them. Removed from this package with no
615
+ replacement here: `deriveSwapSecrets`, `randomSwapSecrets`, `senderPubkeyForRfqSecrets`,
616
+ `preimageForRfqSecrets`, `senderIdentityForRfqSecrets`, `isPerSwapDescriptor`, `derivePreimage`,
617
+ `buildPreimageMessage`, `RFQ_PREIMAGE_TAG`, `isDeterministicSigner`, `adoptSwapDescriptor` (now
618
+ `adoptContractDescriptor` in the SDK), `SwapSecrets` / `DerivedSwapSecrets` /
619
+ `StoredSwapSecrets`, and `rfqSecretsToRecord` / `rfqSecretsOfRecord` — persist a provisioned
620
+ secret with **`swapSecretsToRecord`** from `store` instead, and read P back with
621
+ `contractPreimage`. `RefundNotLocallyPossibleError` and `senderIdentityForSwapRecord` stay here
622
+ (now in `refundBlocked.ts`): they are swap lifecycle, not key provisioning.
623
+ - **No swap record can carry a private key.** `AssetSwap.fallbackSecrets` and the
624
+ `AssetSwapFallbackSecrets` types are deleted rather than kept readable, and `preimageHex` — set
625
+ only when the wallet reports `mustPersistPreimage` — is the record's one secret field. A record
626
+ written by 0.0.1–0.0.3 carries no `signingDescriptor`, so `senderIdentityForSwapRecord` refuses
627
+ it with `no-secrets` rather than silently mis-signing; those versions shipped before any
628
+ consumer, which is the window for doing this without a secret migration.
490
629
  - **`requestLightningSend` / `requestOnchainSend` return `secrets`, not top-level raw key material.**
491
630
  `senderPrivateKey` is gone from both return types; caller-owned onchain preimages live inside
492
631
  `secrets` and must be persisted with the record. `pushRefundWithoutReceiver` /
493
632
  `refundIfUnresolved` take `sender: Identity` instead of `senderPrivateKey: Uint8Array` — build
494
633
  it from the record with `senderIdentityForSwapRecord`, which is what keeps a wallet that cannot
495
- sign reporting `RefundNotLocallyPossibleError` rather than a `TypeError` at the push site;
496
- `senderIdentityForRfqSecrets` is for callers that already hold resolved secrets. `AssetSwap`
497
- gains `signingDescriptor?`,
498
- `preimageHex?`, and complete stored-arm `fallbackSecrets?`. Landed while the package is
499
- unpublished and consumer-free, which is the whole window for doing it: after a consumer ships,
500
- the same change becomes a secret migration across every deployed wallet.
634
+ sign reporting `RefundNotLocallyPossibleError` rather than a `TypeError` at the push site.
635
+ `AssetSwap` gains `signingDescriptor?` and `preimageHex?`.
501
636
  - **Every derived address changed, in both corridors.** The lightning-send lockup moved from the
502
637
  3-leaf program-artifact VHTLC to the 8-leaf `VHTLC.ScriptV2` (non-interactive claim and refund
503
638
  leaves), and the L1 HTLC's claim leaf gained a `SIZE 32 EQUALVERIFY` preimage-length guard. Both
@@ -516,11 +651,11 @@ scanned? })` — the server key is required because a spend is classified by reb
516
651
  indistinguishable from a fill. Leaves have no such failure mode.
517
652
  - **A spend that cannot be classified is no longer restored as `fulfilled`.** It leaves the funding
518
653
  txid unanswered so a later scan decides it. Records are never written on a guess.
519
- - **`AssetSwap` gained the secret-bearing `signingDescriptor?` / `fallbackSecrets?` fields**, and
520
- `preimageHex` narrowed from "the claim preimage P" to "caller-supplied P only". The repository
521
- version stays `1` — the package is unreleased, so there is no stored record to migrate — but a
522
- field-mapped backend must persist the record whole: silently dropping `fallbackSecrets` on write
523
- loses the stored arm's claim and refund keys.
654
+ - **`AssetSwap` gained `signingDescriptor?`**, and `preimageHex` now means "P that cannot be
655
+ re-derived" caller-supplied, or minted for a static descriptor. The repository version stays
656
+ `1` — the package is unreleased, so there is no stored record to migrate — but a field-mapped
657
+ backend must persist the record whole: silently dropping `preimageHex` leaves a static swap
658
+ permanently unclaimable.
524
659
  - **A write that gates something irreversible throws; one that follows it does not.**
525
660
  `addAssetSwap` and `updateAssetSwap` throw on a failed read or write — nothing irreversible may
526
661
  happen until the record is durable, which is why `cancelOffer` writes its `cancelling` marker