@arkade-os/swap 0.0.2 → 0.0.4

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
@@ -16,7 +16,7 @@ Arkade Intents names two participants:
16
16
  contract, and tracks it to a fill or a cancellation.
17
17
  - **solver** — supplies inventory and pricing, and fills the funded contract by delivering
18
18
  `wantAmount` to the user's script over the covenant's `fulfill` path. Some specifications and
19
- repositories use *provider* or *market maker* as synonyms.
19
+ repositories use _provider_ or _market maker_ as synonyms.
20
20
 
21
21
  **`maker` and `taker` in this package name contract positions, not product roles.** The covenant
22
22
  programs bind `makerWP`, and the `Offer` type carries `makerPkScript` and `makerPublicKey`; those
@@ -25,14 +25,14 @@ identify the side that funds the swap and receives `wantAmount`. Read them as sc
25
25
  Arkade Intents documentation deliberately avoids maker and taker for the participants themselves.
26
26
  A resting maker order is firm once taken, and nothing here is: the user funds first, and if no
27
27
  solver fills, the deposit comes back through `cancelOffer` rather than through an executed trade.
28
- Naming the sides *user* and *solver* says who does what without borrowing a guarantee the contract
28
+ Naming the sides _user_ and _solver_ says who does what without borrowing a guarantee the contract
29
29
  does not make.
30
30
 
31
31
  ## Request for quote
32
32
 
33
33
  Every Arkade Intents route is request-for-quote: the user states an intent, receives the solver's
34
34
  terms as a quote, funds the contract it derives from those terms, and a solver fills it. This
35
- route is no exception — what is specific to it is *where the quote is resolved*. `quoteOffer`
35
+ route is no exception — what is specific to it is _where the quote is resolved_. `quoteOffer`
36
36
  prices the swap client-side from the market card the solver publishes: its price feed and its fee,
37
37
  the same two inputs a relay quote would carry. Same protocol, one fewer network hop, and a quote
38
38
  that is ready before the user finishes typing an amount.
@@ -52,7 +52,7 @@ with then.
52
52
 
53
53
  Every swap has the same two beats, on this route and on the cross-ledger corridors:
54
54
 
55
- 1. **Funding** — the user funds the contract it derived from the quote. Funding *is* acceptance;
55
+ 1. **Funding** — the user funds the contract it derived from the quote. Funding _is_ acceptance;
56
56
  there is no accept message to send, here or anywhere in Arkade Intents.
57
57
  2. **Fill, or cancel** — a solver fills by delivering the other side, or the user takes the
58
58
  deposit back.
@@ -90,8 +90,8 @@ funds an offer should keep cancelling within reach.
90
90
  6. **`rfq`** — the user side of quoted swaps: RFQ negotiation over HTTP or a
91
91
  relay, then non-interactive filling (see below). All four reference-solver corridors:
92
92
  `arkade:BTC -> lightning:BTC` and `arkade:BTC -> onchain:BTC` (send), `lightning:BTC ->
93
- arkade:BTC` and `onchain:BTC -> arkade:BTC` (receive), plus `arkade:BTC|asset ->
94
- arkade:BTC|asset` (quote, then take by funding an offer from layer 1).
93
+ arkade:BTC` and `onchain:BTC -> arkade:BTC` (receive), plus `arkade:BTC|asset ->
94
+ arkade:BTC|asset` (quote, then take by funding an offer from layer 1).
95
95
  7. **`onchainHtlc`** — the Bitcoin-L1 side of `arkade:BTC <-> onchain:BTC`: a NUMS-keyed taproot
96
96
  HTLC as pure local derivation (golden-pinned), claim/refund spend builders with signing as a
97
97
  callback, the injected `ChainSource` seam (the package holds no L1 backend and no keys),
@@ -113,11 +113,11 @@ the rest:
113
113
 
114
114
  ```ts
115
115
  // BTC -> asset
116
- const o = await createOffer(wallet, ARK, EMULATOR_PUBKEY, { wantAmount: 1000n, wantAsset });
116
+ const o = await createOffer(wallet, ARK, { wantAmount: 1000n, wantAsset });
117
117
  await wallet.send({ address: o.address, amount: 1000, extensions: [o.extension] });
118
118
 
119
119
  // asset -> BTC (the sats are the VTXO carrier for the asset)
120
- const o = await createOffer(wallet, ARK, EMULATOR_PUBKEY, { wantAmount: 1000n, offerAsset });
120
+ const o = await createOffer(wallet, ARK, { wantAmount: 1000n, offerAsset });
121
121
  await wallet.send({
122
122
  address: o.address,
123
123
  amount: 500,
@@ -126,10 +126,11 @@ await wallet.send({
126
126
  });
127
127
  ```
128
128
 
129
- `EMULATOR_PUBKEY` is the covenant co-signer's x-only key the solver's deployment, not yours.
130
- `createOffer` does not fetch or verify it: clients have no network path to the emulator, only the
131
- solver and covclaimd do. Obtain it out of band, before calling `createOffer`, from the solver's
132
- signed registry/corridor card and check it against whatever value you independently trust.
129
+ The covenant co-signer ("emulator") key defaults to the SDK's per-network pin, resolved from the
130
+ network the Ark server reports never fetched from the emulator itself. Pass
131
+ `params.emulatorPubkey` (33-byte compressed hex, the same contract as `Arkade.connect`'s option)
132
+ to override it for a self-hosted emulator, an unpinned network (signet, testnet), or a key
133
+ rotation the SDK hasn't shipped yet.
133
134
 
134
135
  ### What `createOffer` gives you back
135
136
 
@@ -232,15 +233,9 @@ message anywhere: **acceptance is funding**.
232
233
  import { httpTransport, requestLightningSend } from "@arkade-os/swap";
233
234
 
234
235
  // invoice facts from YOUR OWN decoder — the module takes facts, not a decoder
235
- const swap = await requestLightningSend(
236
- wallet,
237
- arkServerUrl,
238
- emulatorPubkey,
239
- httpTransport(solverUrl),
240
- {
241
- invoice: { raw: bolt11, paymentHash, amountSats, expiresAt },
242
- },
243
- );
236
+ const swap = await requestLightningSend(wallet, arkServerUrl, httpTransport(solverUrl), {
237
+ invoice: { raw: bolt11, paymentHash, amountSats, expiresAt },
238
+ });
244
239
  // quote verified against the LOCAL derivation and gated; now fund and go offline:
245
240
  await wallet.send({ address: swap.address, amount: swap.fundAmount });
246
241
  ```
@@ -256,10 +251,8 @@ which the manager can only poll and cannot retire the row when the swap ends.
256
251
  The trust model is the offer side's, applied to quotes: only `solver_pubkey`,
257
252
  `refund_locktime`, `valid_until` and the amounts are used from a quote; every other contract
258
253
  parameter is the trader's own data, and anything address-shaped from the solver is compare-only
259
- (`AddressMismatch` means refuse-to-fund). `emulatorPubkey` is neither: it is not fetched or
260
- verified by this library at all — clients have no network path to the emulator, only the solver
261
- and covclaimd do — so it must arrive already obtained out of band, from the solver's signed
262
- registry/corridor card, and already checked against whatever value you independently trust.
254
+ (`AddressMismatch` means refuse-to-fund). The emulator key is neither: as above, it is a
255
+ per-network pin inside the SDK, not solver data.
263
256
  Refusals carry a closed reason set (`SwapRefusal`); unknown reasons are a generic decline. The
264
257
  `swap-lightning-send.program.json` bytes are frozen the same way the offer programs are — a
265
258
  golden test pins the compiled leaves and scriptPubKey to the reference solver's exact script.
@@ -310,17 +303,14 @@ import {
310
303
  awaitOnchainFill,
311
304
  claimOnchainFill,
312
305
  addAssetSwap,
313
- preimageForRfqSecrets,
314
- rfqSecretsToRecord,
306
+ swapSecretsToRecord,
315
307
  } from "@arkade-os/swap";
316
308
 
317
- const swap = await requestOnchainSend(
318
- wallet,
319
- arkServerUrl,
320
- emulatorPubkey,
321
- httpTransport(solverUrl),
322
- { amount: 100_000, amountSide: "to", payoutPubkey },
323
- );
309
+ const swap = await requestOnchainSend(wallet, arkServerUrl, httpTransport(solverUrl), {
310
+ amount: 100_000,
311
+ amountSide: "to",
312
+ payoutPubkey,
313
+ });
324
314
  // Persist the record, including secrets, BEFORE funding. This must succeed:
325
315
  // if addAssetSwap throws, do not call wallet.send.
326
316
  await addAssetSwap(repository, {
@@ -332,7 +322,7 @@ await addAssetSwap(repository, {
332
322
  swapPkScript: hex.encode(swap.swapPkScript),
333
323
  htlcPkScriptHex: hex.encode(swap.htlc.pkScript),
334
324
  htlcLocktime: swap.htlc.refundLocktime,
335
- ...rfqSecretsToRecord(swap.secrets),
325
+ ...swapSecretsToRecord(swap.secrets),
336
326
  });
337
327
  await wallet.send({ address: swap.address, amount: swap.fundAmount });
338
328
 
@@ -342,7 +332,7 @@ const utxo = await awaitOnchainFill(chain, swap.htlc, minConfirmations);
342
332
  await claimOnchainFill(chain, {
343
333
  htlc: swap.htlc,
344
334
  utxo,
345
- preimage: await preimageForRfqSecrets(wallet, swap.secrets),
335
+ preimage: swap.secrets.preimage,
346
336
  payoutPkScript,
347
337
  feeRateSatVb,
348
338
  sign,
@@ -364,11 +354,9 @@ Crash recovery is record-driven, not chain-driven: `classifyOnchainHtlc` re-deri
364
354
  state (unfunded / awaiting confirmations / claimable / refundable / claimed-with-P / swept) from
365
355
  `ChainSource` plus the stored outpoint — without the stored record a spent HTLC is
366
356
  indistinguishable from an unfunded one, which is why persisting before funding is mandatory. The
367
- `AssetSwap` record carries the onchain fields (`paymentHash`, `signingDescriptor`,
368
- `preimageHex` for caller-supplied P, `fallbackSecrets`, `htlcPkScriptHex`, `htlcLocktime`,
369
- `l1Txid`) and the statuses `awaiting_fill / claimable / claimed / refunded_l1`.
370
- `fallbackSecrets` is versioned and discriminated: `{ version: 1, type: "stored",
371
- 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`.
372
360
 
373
361
  **On-board corridors are covered.** `requestLightningReceive` (`lightning:BTC -> arkade:BTC`) and
374
362
  `requestOnchainReceive` (`onchain:BTC -> arkade:BTC`) mirror the send-side flows: quote → derive
@@ -377,7 +365,7 @@ against the quote's compare-only addresses → gate. `requestLightningReceive` r
377
365
  hold invoice to pay; `requestOnchainReceive` returns the L1 HTLC to fund — the payment/broadcast
378
366
  itself is the trader's own wallet's job, exactly as on the send corridors.
379
367
 
380
- The invoice on the lightning-receive leg is the *solver's*, so the SDK owns the comparison rather
368
+ The invoice on the lightning-receive leg is the _solver's_, so the SDK owns the comparison rather
381
369
  than taking the caller's facts about it: `requestLightningReceive` requires a `decodeInvoice`
382
370
  callback (no BOLT11 dependency is added) and `verifyReceiveInvoice` binds the decoded invoice to
383
371
  this swap's `H` and to `quote.from_amount` — an invoice on another payment hash is the one attack
@@ -403,11 +391,11 @@ implementation and marked provisional (`TODO(claim-packet-vectors)`).
403
391
 
404
392
  `RfqSwapManager` drives the lightning-receive leg too, as `kind: "lightning_receive"` records
405
393
  carrying `expectedAmount` and wired to a `claimLockup` callback (`pushClaim`, with `expectedAmount`
406
- and `partiallyClaimed` passed through — the manager's value check decides *when* to act, the inner
394
+ and `partiallyClaimed` passed through — the manager's value check decides _when_ to act, the inner
407
395
  one decides whether `P` is published). Nothing is asked of the solver: the reference solver's
408
396
  `rfq_status_request` consults neither receive store, so a status poll answers `unknown` for every
409
397
  one of these swaps and chain observation is the only workable design. States mean what they do on
410
- the send legs with the roles swapped — `settled` is *our own* claim landing, matched by a
398
+ the send legs with the roles swapped — `settled` is _our own_ claim landing, matched by a
411
399
  hash-verified preimage spend rather than by the txid we submitted, so a claim that lands without us
412
400
  still counts; `claimed` is a local belief and not terminal; and **`refunded` is a loss**, the solver
413
401
  having taken back a lockup we failed to claim. A lockup funded below `expectedAmount` is reported
@@ -430,29 +418,59 @@ Lightning HTLC lapses, and **the payer is refunded** — the trader loses the in
430
418
  funds it was holding. Which is why staying online to claim is an obligation and not a preference:
431
419
  covclaimd cannot claim this covenant today, so the claim packet's offline path does not yet run.
432
420
 
433
- ## RFQ secrets are derived, not stored
421
+ ## Swap secrets come from the wallet, not from this package
422
+
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 | random, stored on the record | the preimage only |
434
443
 
435
- The two secrets an RFQ swap needs the VHTLC `sender` key and, for an onchain send, the preimage —
436
- are functions of the wallet seed plus one HD-allocated descriptor. The record keeps the descriptor,
437
- which is public, so a copied browser profile or a device backup yields nothing spendable.
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.
438
450
 
439
451
  ```ts
440
452
  const swap = await requestOnchainSend(/* … */);
441
- swap.secrets; // { derivable: true, signingDescriptor } — persist it, it holds no secret
442
- await saveSwap({ ...record, ...rfqSecretsToRecord(swap.secrets) });
443
-
444
- // Later, from the seed plus that descriptor. Guard the lookup: offer-corridor
445
- // records (and records that lost their secrets fields) carry no secrets at
446
- // all, and a `!` here would crash the whole recovery loop on the first one.
447
- const secrets = rfqSecretsOfRecord(record);
448
- if (secrets) {
449
- const preimage = await preimageForRfqSecrets(wallet, secrets);
453
+ // `swapSecretsToRecord` stores the public descriptor always, and `preimageHex`
454
+ // only when the wallet said it cannot re-derive P.
455
+ await saveSwap({ ...record, ...swapSecretsToRecord(swap.secrets) });
456
+
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
+ );
450
467
  }
451
468
 
452
469
  // For a refund, take the composition instead of the guard: it turns all three
453
- // ways a wallet can fail to produce the sender key — no secrets on the record,
454
- // an unreadable fallback arm, a descriptor from another seed into one typed
455
- // `RefundNotLocallyPossibleError` carrying which. Wire `refundArkade` to this.
470
+ // ways a wallet can fail to produce the sender key — the record names no
471
+ // descriptor, the descriptor is another seed's, the wallet holds the key but
472
+ // cannot sign into one typed `RefundNotLocallyPossibleError` carrying which,
473
+ // and lets a signer outage stay retryable. Wire `refundArkade` to this.
456
474
  const sender = await senderIdentityForSwapRecord(wallet, record);
457
475
  ```
458
476
 
@@ -463,17 +481,15 @@ terminal: the lockup stays funded and watched, a solver claim still ends the swa
463
481
  `pending`. The manager reports the same state when nothing is wired to act (`enableAutoActions:
464
482
  false`, or no callbacks) and the window has passed.
465
483
 
466
- `derivable: false` is the fallback for wallets that cannot allocate (static / `auto` / custom
467
- signers). It carries the raw `senderPrivateKey` and, for onchain sends, `preimage`;
468
- `rfqSecretsToRecord` stores them under `AssetSwap.fallbackSecrets` as a complete versioned
469
- record. The discriminant is a type-level fact, so a consumer written against the derivable arm
470
- alone will not compile against the fallback. A caller-supplied preimage on an HD wallet keeps
471
- `signingDescriptor` for the sender key and stores only `preimageHex` as secret material.
484
+ A caller-supplied preimage keeps `signingDescriptor` for the sender key and stores only
485
+ `preimageHex` as secret material.
472
486
 
473
- Each swap **allocates** its own descriptor rather than peeking at the current one: two swaps sharing
474
- a descriptor derive the *identical* preimage, so one solver learning its own preimage would learn the
475
- other swap's. On restore, `adoptSwapDescriptor` moves the wallet's watermark past a restored record's
476
- index so it cannot be handed out twice.
487
+ On an HD wallet each swap **allocates** its own descriptor rather than peeking at the current one:
488
+ 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`
491
+ (from `@arkade-os/sdk`) moves the wallet's watermark past a restored record's index so it cannot be
492
+ handed out twice; a static descriptor names no index and adopts as a no-op.
477
493
 
478
494
  The derivation is `sha256(signSchnorrDeterministic(sha256("Arkade-RFQ-Preimage-v1" ‖ xonly(32) ‖
479
495
  u32le(0))))`, mirroring NArk's Boltz scheme (`SwapsManagementService.cs:128-160`) with an
@@ -486,27 +502,43 @@ too little public quote data to rediscover, so the record remains required.
486
502
 
487
503
  **Gap-limit interaction:** every swap request — including one whose quote is refused — consumes one
488
504
  index from the wallet's receive stream, and a swap index never becomes a funded receive contract,
489
- so it looks *unused* to a seed-only `restore()` gap scan. Many consecutive swap allocations between
505
+ so it looks _unused_ to a seed-only `restore()` gap scan. Many consecutive swap allocations between
490
506
  two funded receive indices can therefore exceed the scan's `gapLimit` (default 20) and stop it
491
507
  before later-funded addresses are found. Keep the swap repository in backups (restore then adopts
492
- each record's descriptor via `adoptSwapDescriptor`), or raise `gapLimit` on seed-only restores
508
+ each record's descriptor via `adoptContractDescriptor`), or raise `gapLimit` on seed-only restores
493
509
  after heavy swap use.
494
510
 
495
511
  ## Breaking changes on this branch (pre-release migration notes)
496
512
 
497
513
  The package is pre-release; these notes replace a changelog for consumers tracking the branch.
498
514
 
515
+ - **`secrets.ts` is gone; key provisioning moved into `@arkade-os/sdk`.** This package no longer
516
+ derives, mints, or names keys. It asks the SDK for what the leg needs — `provisionRefundKey(wallet)`
517
+ for a leg it funds, `provisionClaimSecret(wallet, { preimage? })` for one it claims — and
518
+ recovers with `contractSigner(wallet, descriptor)` / `contractPreimage(wallet, descriptor,
519
+ stored?)`. The returned `ProvisionedKey` / `ProvisionedClaimSecret` replace `SwapSecrets`, and
520
+ `descriptor` replaces `signingDescriptor` on them. Removed from this package with no
521
+ replacement here: `deriveSwapSecrets`, `randomSwapSecrets`, `senderPubkeyForRfqSecrets`,
522
+ `preimageForRfqSecrets`, `senderIdentityForRfqSecrets`, `isPerSwapDescriptor`, `derivePreimage`,
523
+ `buildPreimageMessage`, `RFQ_PREIMAGE_TAG`, `isDeterministicSigner`, `adoptSwapDescriptor` (now
524
+ `adoptContractDescriptor` in the SDK), `SwapSecrets` / `DerivedSwapSecrets` /
525
+ `StoredSwapSecrets`, and `rfqSecretsToRecord` / `rfqSecretsOfRecord` — persist a provisioned
526
+ secret with **`swapSecretsToRecord`** from `store` instead, and read P back with
527
+ `contractPreimage`. `RefundNotLocallyPossibleError` and `senderIdentityForSwapRecord` stay here
528
+ (now in `refundBlocked.ts`): they are swap lifecycle, not key provisioning.
529
+ - **No swap record can carry a private key.** `AssetSwap.fallbackSecrets` and the
530
+ `AssetSwapFallbackSecrets` types are deleted rather than kept readable, and `preimageHex` — set
531
+ only when the wallet reports `mustPersistPreimage` — is the record's one secret field. A record
532
+ written by 0.0.1–0.0.3 carries no `signingDescriptor`, so `senderIdentityForSwapRecord` refuses
533
+ it with `no-secrets` rather than silently mis-signing; those versions shipped before any
534
+ consumer, which is the window for doing this without a secret migration.
499
535
  - **`requestLightningSend` / `requestOnchainSend` return `secrets`, not top-level raw key material.**
500
536
  `senderPrivateKey` is gone from both return types; caller-owned onchain preimages live inside
501
537
  `secrets` and must be persisted with the record. `pushRefundWithoutReceiver` /
502
538
  `refundIfUnresolved` take `sender: Identity` instead of `senderPrivateKey: Uint8Array` — build
503
539
  it from the record with `senderIdentityForSwapRecord`, which is what keeps a wallet that cannot
504
- sign reporting `RefundNotLocallyPossibleError` rather than a `TypeError` at the push site;
505
- `senderIdentityForRfqSecrets` is for callers that already hold resolved secrets. `AssetSwap`
506
- gains `signingDescriptor?`,
507
- `preimageHex?`, and complete stored-arm `fallbackSecrets?`. Landed while the package is
508
- unpublished and consumer-free, which is the whole window for doing it: after a consumer ships,
509
- the same change becomes a secret migration across every deployed wallet.
540
+ sign reporting `RefundNotLocallyPossibleError` rather than a `TypeError` at the push site.
541
+ `AssetSwap` gains `signingDescriptor?` and `preimageHex?`.
510
542
  - **Every derived address changed, in both corridors.** The lightning-send lockup moved from the
511
543
  3-leaf program-artifact VHTLC to the 8-leaf `VHTLC.ScriptV2` (non-interactive claim and refund
512
544
  leaves), and the L1 HTLC's claim leaf gained a `SIZE 32 EQUALVERIFY` preimage-length guard. Both
@@ -525,11 +557,11 @@ scanned? })` — the server key is required because a spend is classified by reb
525
557
  indistinguishable from a fill. Leaves have no such failure mode.
526
558
  - **A spend that cannot be classified is no longer restored as `fulfilled`.** It leaves the funding
527
559
  txid unanswered so a later scan decides it. Records are never written on a guess.
528
- - **`AssetSwap` gained the secret-bearing `signingDescriptor?` / `fallbackSecrets?` fields**, and
529
- `preimageHex` narrowed from "the claim preimage P" to "caller-supplied P only". The repository
530
- version stays `1` — the package is unreleased, so there is no stored record to migrate — but a
531
- field-mapped backend must persist the record whole: silently dropping `fallbackSecrets` on write
532
- loses the stored arm's claim and refund keys.
560
+ - **`AssetSwap` gained `signingDescriptor?`**, and `preimageHex` now means "P that cannot be
561
+ re-derived" caller-supplied, or minted for a static descriptor. The repository version stays
562
+ `1` — the package is unreleased, so there is no stored record to migrate — but a field-mapped
563
+ backend must persist the record whole: silently dropping `preimageHex` leaves a static swap
564
+ permanently unclaimable.
533
565
  - **A write that gates something irreversible throws; one that follows it does not.**
534
566
  `addAssetSwap` and `updateAssetSwap` throw on a failed read or write — nothing irreversible may
535
567
  happen until the record is durable, which is why `cancelOffer` writes its `cancelling` marker