@unicitylabs/sphere-sdk 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/connect/index.cjs +11 -5
  2. package/dist/connect/index.cjs.map +1 -1
  3. package/dist/connect/index.d.cts +4 -1
  4. package/dist/connect/index.d.ts +4 -1
  5. package/dist/connect/index.js +11 -5
  6. package/dist/connect/index.js.map +1 -1
  7. package/dist/core/index.cjs +566 -171
  8. package/dist/core/index.cjs.map +1 -1
  9. package/dist/core/index.d.cts +67 -0
  10. package/dist/core/index.d.ts +67 -0
  11. package/dist/core/index.js +566 -171
  12. package/dist/core/index.js.map +1 -1
  13. package/dist/impl/browser/connect/index.cjs +11 -5
  14. package/dist/impl/browser/connect/index.cjs.map +1 -1
  15. package/dist/impl/browser/connect/index.d.cts +3 -1
  16. package/dist/impl/browser/connect/index.d.ts +3 -1
  17. package/dist/impl/browser/connect/index.js +11 -5
  18. package/dist/impl/browser/connect/index.js.map +1 -1
  19. package/dist/impl/nodejs/connect/index.cjs +10 -4
  20. package/dist/impl/nodejs/connect/index.cjs.map +1 -1
  21. package/dist/impl/nodejs/connect/index.d.cts +1 -1
  22. package/dist/impl/nodejs/connect/index.d.ts +1 -1
  23. package/dist/impl/nodejs/connect/index.js +10 -4
  24. package/dist/impl/nodejs/connect/index.js.map +1 -1
  25. package/dist/impl/nodejs/index.d.cts +11 -0
  26. package/dist/impl/nodejs/index.d.ts +11 -0
  27. package/dist/impl/shared/wallet-api/index.d.cts +11 -0
  28. package/dist/impl/shared/wallet-api/index.d.ts +11 -0
  29. package/dist/impl/wallet-api-v2/index.cjs +2 -0
  30. package/dist/impl/wallet-api-v2/index.cjs.map +1 -1
  31. package/dist/impl/wallet-api-v2/index.d.cts +13 -0
  32. package/dist/impl/wallet-api-v2/index.d.ts +13 -0
  33. package/dist/impl/wallet-api-v2/index.js +2 -0
  34. package/dist/impl/wallet-api-v2/index.js.map +1 -1
  35. package/dist/index.cjs +566 -171
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +61 -1
  38. package/dist/index.d.ts +61 -1
  39. package/dist/index.js +566 -171
  40. package/dist/index.js.map +1 -1
  41. package/dist/modules/payments-v2/index.cjs +609 -269
  42. package/dist/modules/payments-v2/index.cjs.map +1 -1
  43. package/dist/modules/payments-v2/index.d.cts +88 -13
  44. package/dist/modules/payments-v2/index.d.ts +88 -13
  45. package/dist/modules/payments-v2/index.js +609 -269
  46. package/dist/modules/payments-v2/index.js.map +1 -1
  47. package/dist/token-engine/index.cjs +132 -34
  48. package/dist/token-engine/index.cjs.map +1 -1
  49. package/dist/token-engine/index.d.cts +19 -0
  50. package/dist/token-engine/index.d.ts +19 -0
  51. package/dist/token-engine/index.js +132 -34
  52. package/dist/token-engine/index.js.map +1 -1
  53. package/package.json +1 -1
@@ -8127,6 +8127,8 @@ var FETCH_TIMEOUT_MS = 1e4;
8127
8127
  var TokenRegistry = class _TokenRegistry {
8128
8128
  static instance = null;
8129
8129
  definitionsById;
8130
+ /** Non-fungible definitions keyed by TOKEN TYPE — a separate namespace (#147). */
8131
+ definitionsByType;
8130
8132
  definitionsBySymbol;
8131
8133
  definitionsByName;
8132
8134
  // Remote refresh state
@@ -8145,6 +8147,7 @@ var TokenRegistry = class _TokenRegistry {
8145
8147
  inFlight = null;
8146
8148
  constructor() {
8147
8149
  this.definitionsById = /* @__PURE__ */ new Map();
8150
+ this.definitionsByType = /* @__PURE__ */ new Map();
8148
8151
  this.definitionsBySymbol = /* @__PURE__ */ new Map();
8149
8152
  this.definitionsByName = /* @__PURE__ */ new Map();
8150
8153
  }
@@ -8372,9 +8375,11 @@ var TokenRegistry = class _TokenRegistry {
8372
8375
  this.definitionsById.clear();
8373
8376
  this.definitionsBySymbol.clear();
8374
8377
  this.definitionsByName.clear();
8378
+ this.definitionsByType.clear();
8375
8379
  for (const def of definitions) {
8376
8380
  const idLower = def.id.toLowerCase();
8377
8381
  this.definitionsById.set(idLower, def);
8382
+ if (def.assetKind === "non-fungible") this.definitionsByType.set(idLower, def);
8378
8383
  if (def.symbol) {
8379
8384
  this.definitionsBySymbol.set(def.symbol.toUpperCase(), def);
8380
8385
  }
@@ -8588,6 +8593,20 @@ var TokenRegistry = class _TokenRegistry {
8588
8593
  getAllDefinitions() {
8589
8594
  return Array.from(this.definitionsById.values());
8590
8595
  }
8596
+ /**
8597
+ * Definition for a coinless token's genesis TOKEN TYPE (#777). Never falls back
8598
+ * to `getDefinition`: a type and a coin id live in different namespaces, so a
8599
+ * type that collided with a coin id would otherwise render as that coin.
8600
+ */
8601
+ getTypeMeta(tokenType) {
8602
+ const def = this.getTypeDefinition(tokenType);
8603
+ if (!def) return null;
8604
+ return { name: def.name, iconUrl: def.icons?.[0]?.url ?? null };
8605
+ }
8606
+ getTypeDefinition(tokenType) {
8607
+ if (!tokenType) return void 0;
8608
+ return this.definitionsByType.get(tokenType.toLowerCase());
8609
+ }
8591
8610
  /**
8592
8611
  * Get all fungible token definitions
8593
8612
  * @returns Array of fungible token definitions
@@ -9423,6 +9442,8 @@ import { DataHasherFactory } from "@unicitylabs/state-transition-sdk/lib/crypto/
9423
9442
  import { CborSerializer } from "@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborSerializer.js";
9424
9443
  import { CborDeserializer } from "@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborDeserializer.js";
9425
9444
  import { CborError } from "@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborError.js";
9445
+ import { CborReader } from "@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborReader.js";
9446
+ import { MajorType } from "@unicitylabs/state-transition-sdk/lib/serialization/cbor/MajorType.js";
9426
9447
  import { Asset } from "@unicitylabs/state-transition-sdk/lib/payment/asset/Asset.js";
9427
9448
  import { AssetId } from "@unicitylabs/state-transition-sdk/lib/payment/asset/AssetId.js";
9428
9449
  import { PaymentAssetCollection } from "@unicitylabs/state-transition-sdk/lib/payment/asset/PaymentAssetCollection.js";
@@ -9835,6 +9856,126 @@ async function burntTokenFromCheckpoint(deps, storedBytes, reDerivedBurnTx, sour
9835
9856
  return burntToken;
9836
9857
  }
9837
9858
 
9859
+ // token-engine/value-envelope.ts
9860
+ function isCoinlessEnvelope(envelope) {
9861
+ return envelope.startsWith("none_");
9862
+ }
9863
+ var SELF_DESCRIBED_CBOR_TAG = 55799n;
9864
+ var CBOR_MAJOR_TYPE_MASK = 224;
9865
+ var describe = (error) => error instanceof Error ? error.message : String(error);
9866
+ var invalid = (message) => new SphereError(message, "VALIDATION_ERROR");
9867
+ function majorTypeOf(bytes) {
9868
+ const first = bytes.at(0);
9869
+ return first === void 0 ? null : first & CBOR_MAJOR_TYPE_MASK;
9870
+ }
9871
+ function headTagNumber(bytes) {
9872
+ try {
9873
+ return new CborReader(bytes).readLength(MajorType.TAG);
9874
+ } catch {
9875
+ return null;
9876
+ }
9877
+ }
9878
+ function isAssetShaped(item) {
9879
+ try {
9880
+ return CborDeserializer.decodeArray(item, 2).every(
9881
+ (field) => majorTypeOf(field) === MajorType.BYTE_STRING
9882
+ );
9883
+ } catch {
9884
+ return false;
9885
+ }
9886
+ }
9887
+ function arrayItemsOrNull(bytes) {
9888
+ try {
9889
+ return CborDeserializer.decodeArray(bytes);
9890
+ } catch {
9891
+ return null;
9892
+ }
9893
+ }
9894
+ function decodeSphere(genesisData) {
9895
+ try {
9896
+ return SpherePaymentData.fromCBOR(genesisData).toValue();
9897
+ } catch (error) {
9898
+ throw invalid(`Failed to decode token payment data: ${describe(error)}`);
9899
+ }
9900
+ }
9901
+ function taggedItemBody(genesisData) {
9902
+ try {
9903
+ return CborDeserializer.decodeTag(genesisData).data;
9904
+ } catch (error) {
9905
+ throw invalid(
9906
+ `Token value payload is a CBOR tag whose item is malformed or carries trailing bytes: ${describe(error)}`
9907
+ );
9908
+ }
9909
+ }
9910
+ function selfDescribedBodyCarriesValue(body) {
9911
+ const major = majorTypeOf(body);
9912
+ if (major === MajorType.TAG) {
9913
+ const inner = headTagNumber(body);
9914
+ return inner === SpherePaymentData.CBOR_TAG || inner === SELF_DESCRIBED_CBOR_TAG;
9915
+ }
9916
+ if (major === MajorType.ARRAY) {
9917
+ const items = arrayItemsOrNull(body);
9918
+ return items !== null && items.some(isAssetShaped);
9919
+ }
9920
+ return false;
9921
+ }
9922
+ function classifyTagged(genesisData) {
9923
+ const tag = headTagNumber(genesisData);
9924
+ if (tag === null) {
9925
+ throw invalid(
9926
+ "Token value payload has an unreadable or non-canonically encoded CBOR tag head \u2014 it could be a malformed SpherePaymentData envelope"
9927
+ );
9928
+ }
9929
+ if (tag === SpherePaymentData.CBOR_TAG) {
9930
+ return { envelope: "sphere", value: decodeSphere(genesisData) };
9931
+ }
9932
+ const body = taggedItemBody(genesisData);
9933
+ if (tag === SELF_DESCRIBED_CBOR_TAG && selfDescribedBodyCarriesValue(body)) {
9934
+ throw invalid(
9935
+ "Token value payload is a value envelope wrapped in the self-described CBOR tag 55799 (RFC 8949 \xA73.4.6), which no codec here unwraps \u2014 its coins would be invisible. Emit the envelope without the self-describe prefix"
9936
+ );
9937
+ }
9938
+ return { envelope: "none_tag", value: null };
9939
+ }
9940
+ function classifyArray(genesisData) {
9941
+ const items = arrayItemsOrNull(genesisData);
9942
+ if (items === null) {
9943
+ throw invalid("Token value payload is not PaymentAssetCollection: malformed CBOR array");
9944
+ }
9945
+ return items.some(isAssetShaped) ? { envelope: "bare_collection", value: null } : { envelope: "none_other", value: null };
9946
+ }
9947
+ function classifyValueEnvelope(genesisData) {
9948
+ if (genesisData === null) return { envelope: "none_absent", value: null };
9949
+ const major = majorTypeOf(genesisData);
9950
+ if (major === null) return { envelope: "none_absent", value: null };
9951
+ if (major === MajorType.TAG) return classifyTagged(genesisData);
9952
+ if (major === MajorType.ARRAY) return classifyArray(genesisData);
9953
+ return { envelope: "none_other", value: null };
9954
+ }
9955
+ function wrapToken(sdkToken) {
9956
+ const { envelope, value } = classifyValueEnvelope(sdkToken.genesis.data);
9957
+ const blob = {
9958
+ tokenId: HexConverter.encode(sdkToken.id.bytes),
9959
+ token: sdkToken.toCBOR()
9960
+ };
9961
+ return {
9962
+ sdkToken,
9963
+ blob,
9964
+ value,
9965
+ valueEnvelope: envelope,
9966
+ tokenType: HexConverter.encode(sdkToken.type.bytes)
9967
+ };
9968
+ }
9969
+ function assertMintableData(data) {
9970
+ try {
9971
+ classifyValueEnvelope(data);
9972
+ } catch (error) {
9973
+ throw invalid(
9974
+ `Cannot mint a data token with this payload: ${describe(error)}. Raw bytes starting in the CBOR array (0x80-0x9f) or tag (0xc0-0xdf) range must be well-formed canonical CBOR; wrap them in a CBOR byte string, map, text string, or a tag other than 39050/55799.`
9975
+ );
9976
+ }
9977
+ }
9978
+
9838
9979
  // token-engine/SphereTokenEngine.ts
9839
9980
  var DEFAULT_PROOF_POLL_INTERVAL_MS = 300;
9840
9981
  var MAX_MINT_CONCURRENCY = 8;
@@ -9891,7 +10032,7 @@ var SphereTokenEngine = class {
9891
10032
  return sdkToken.latestTransaction.data;
9892
10033
  }
9893
10034
  const data = sdkToken.genesis.data;
9894
- if (data && this.isSpherePaymentData(data)) {
10035
+ if (data && classifyValueEnvelope(data).envelope === "sphere") {
9895
10036
  return SpherePaymentData.fromCBOR(data).memo;
9896
10037
  }
9897
10038
  return null;
@@ -9921,9 +10062,10 @@ var SphereTokenEngine = class {
9921
10062
  );
9922
10063
  const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
9923
10064
  const token = await Token.mint(certified, this.deps.verificationContext);
9924
- return this.wrapToken(token);
10065
+ return wrapToken(token);
9925
10066
  }
9926
10067
  async mintDataToken(params, options) {
10068
+ assertMintableData(params.data);
9927
10069
  const recipient = SignaturePredicate.create(params.recipientPubkey);
9928
10070
  const tokenType = params.tokenType ? new TokenType(params.tokenType) : TokenType.generate();
9929
10071
  const salt = params.salt ? TokenSalt.fromBytes(params.salt) : TokenSalt.generate();
@@ -9938,7 +10080,7 @@ var SphereTokenEngine = class {
9938
10080
  );
9939
10081
  const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
9940
10082
  const token = await Token.mint(certified, this.deps.verificationContext);
9941
- return this.wrapToken(token);
10083
+ return wrapToken(token);
9942
10084
  }
9943
10085
  async transfer(params, options) {
9944
10086
  this.assertOwned(params.token);
@@ -9961,13 +10103,19 @@ var SphereTokenEngine = class {
9961
10103
  );
9962
10104
  const certified = await transferTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
9963
10105
  const transferred = await params.token.sdkToken.transfer(certified, this.deps.verificationContext);
9964
- return this.wrapToken(transferred);
10106
+ return wrapToken(transferred);
9965
10107
  }
9966
10108
  async split(params, options) {
9967
10109
  this.assertOwned(params.token);
9968
10110
  if (params.outputs.length === 0) {
9969
10111
  throw new SphereError("Split requires at least one output", "VALIDATION_ERROR");
9970
10112
  }
10113
+ if (params.token.value === null) {
10114
+ throw new SphereError(
10115
+ `Cannot split token ${params.token.blob.tokenId}: its genesis payload carries no value this SDK can read (envelope: ${params.token.valueEnvelope}). A coinless token can only be transferred whole.`,
10116
+ "VALIDATION_ERROR"
10117
+ );
10118
+ }
9971
10119
  const transferId = this.resolveTransferId(options);
9972
10120
  const requests = params.outputs.map(
9973
10121
  (o, i) => SplitTokenRequest.create(
@@ -10089,7 +10237,7 @@ var SphereTokenEngine = class {
10089
10237
  const proof = await this.submitSplitMintLeg(certData, mintTx, options);
10090
10238
  const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
10091
10239
  const token = await Token.mint(certified, this.deps.verificationContext);
10092
- return this.wrapToken(token);
10240
+ return wrapToken(token);
10093
10241
  }
10094
10242
  /**
10095
10243
  * Submit one split-mint leg. A `TRANSACTION_HASH_MISMATCH` on a mint leg is NOT a foreign spend
@@ -10147,7 +10295,7 @@ var SphereTokenEngine = class {
10147
10295
  "VALIDATION_ERROR"
10148
10296
  );
10149
10297
  }
10150
- return this.wrapToken(sdkToken);
10298
+ return wrapToken(sdkToken);
10151
10299
  }
10152
10300
  // ── internals ────────────────────────────────────────────────────────────────
10153
10301
  /**
@@ -10259,34 +10407,6 @@ var SphereTokenEngine = class {
10259
10407
  throw new SphereError("Cannot transfer a token not owned by this engine identity", "VALIDATION_ERROR");
10260
10408
  }
10261
10409
  }
10262
- /** Wrap an SDK token into a SphereToken: cache its blob (incl. stable tokenId) + decoded value. */
10263
- wrapToken(sdkToken) {
10264
- const data = sdkToken.genesis.data;
10265
- let value = null;
10266
- if (data && this.isSpherePaymentData(data)) {
10267
- try {
10268
- value = SpherePaymentData.fromCBOR(data).toValue();
10269
- } catch (err) {
10270
- throw new SphereError(
10271
- `Failed to decode token payment data: ${err instanceof Error ? err.message : String(err)}`,
10272
- "VALIDATION_ERROR"
10273
- );
10274
- }
10275
- }
10276
- const blob = {
10277
- tokenId: HexConverter.encode(sdkToken.id.bytes),
10278
- token: sdkToken.toCBOR()
10279
- };
10280
- return { sdkToken, blob, value };
10281
- }
10282
- /** True if the bytes are a SpherePaymentData envelope (value token) vs a raw data token. */
10283
- isSpherePaymentData(data) {
10284
- try {
10285
- return CborDeserializer.decodeTag(data).tag === SpherePaymentData.CBOR_TAG;
10286
- } catch {
10287
- return false;
10288
- }
10289
- }
10290
10410
  /**
10291
10411
  * Terminate the verification worker pool, if this engine was configured with
10292
10412
  * one. Idempotent — the pool's dispose only touches workers it spawned.
@@ -11682,6 +11802,14 @@ function createMachineStores(kv) {
11682
11802
  };
11683
11803
  }
11684
11804
 
11805
+ // modules/payments-v2/machine/payload-view.ts
11806
+ function splitOf(p) {
11807
+ return p.kind === "coin" ? p.split : void 0;
11808
+ }
11809
+ function coinIdOf(p) {
11810
+ return p.kind === "coin" ? p.coinId : void 0;
11811
+ }
11812
+
11685
11813
  // modules/payments-v2/machine/TransferMachine.ts
11686
11814
  var CERTIFY_WIDTH = 20;
11687
11815
  var KEEP_OPEN_CODES = /* @__PURE__ */ new Set([
@@ -11884,13 +12012,14 @@ var TransferMachine = class {
11884
12012
  const finished = await engine.transfer({ token: source, recipientPubkey }, opts);
11885
12013
  return { recipientBlob: finished.blob.token };
11886
12014
  }
11887
- const split = ctx.payload.split;
12015
+ const split = splitOf(ctx.payload);
12016
+ const splitCoinId = coinIdOf(ctx.payload);
11888
12017
  const { outputs } = await engine.split(
11889
12018
  {
11890
12019
  token: source,
11891
12020
  outputs: [
11892
- { recipientPubkey, coinId: ctx.payload.coinId, amount: BigInt(split.splitAmount) },
11893
- { recipientPubkey: this.deps.ownPubkey, coinId: ctx.payload.coinId, amount: BigInt(split.remainderAmount) }
12021
+ { recipientPubkey, coinId: splitCoinId, amount: BigInt(split.splitAmount) },
12022
+ { recipientPubkey: this.deps.ownPubkey, coinId: splitCoinId, amount: BigInt(split.remainderAmount) }
11894
12023
  ]
11895
12024
  },
11896
12025
  opts
@@ -12012,7 +12141,9 @@ var TransferMachine = class {
12012
12141
  const entry = {
12013
12142
  transferId: r.transferId,
12014
12143
  remainingAmount: undelivered.toString(),
12015
- coinId: r.payload.coinId,
12144
+ // '' only on a token-addressed intent, which is one leg: it either lands or
12145
+ // conflicts, so a shortfall row is unreachable there (settlePartial needs >1).
12146
+ coinId: coinIdOf(r.payload) ?? "",
12016
12147
  recipient: r.payload.recipient,
12017
12148
  committedTokenIds: committed.map((o) => o.op.sourceTokenId),
12018
12149
  createdAt: this.deps.now()
@@ -12038,7 +12169,8 @@ var TransferMachine = class {
12038
12169
  }
12039
12170
  async finishBookkeeping(engine, transferId, payload, phase, committed) {
12040
12171
  try {
12041
- const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
12172
+ const coinId = coinIdOf(payload);
12173
+ const committedAmount = coinId === void 0 ? "0" : await this.settledAmount(engine, coinId, committed);
12042
12174
  await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
12043
12175
  } catch {
12044
12176
  }
@@ -12081,8 +12213,11 @@ var TransferMachine = class {
12081
12213
  }
12082
12214
  };
12083
12215
  function conflictAmount(engine, payload, op, source) {
12084
- if (op.kind === "split") return BigInt(payload.split.splitAmount);
12085
- return source !== void 0 ? engine.balanceOf(source, payload.coinId) : 0n;
12216
+ const split = splitOf(payload);
12217
+ if (op.kind === "split") return BigInt(split.splitAmount);
12218
+ const coinId = coinIdOf(payload);
12219
+ if (coinId === void 0) return 0n;
12220
+ return source !== void 0 ? engine.balanceOf(source, coinId) : 0n;
12086
12221
  }
12087
12222
 
12088
12223
  // modules/payments-v2/machine/resume.ts
@@ -12189,6 +12324,14 @@ async function runOne(ctx, job, report) {
12189
12324
  report.failed.push(job.transferId);
12190
12325
  return;
12191
12326
  }
12327
+ if (job.payload.kind === "coinless" && !isCoinlessEnvelope(token.valueEnvelope)) {
12328
+ logger.warn(
12329
+ "PaymentsV2",
12330
+ `resume: token intent ${job.transferId} names a source carrying coin value \u2014 refusing`
12331
+ );
12332
+ report.failed.push(job.transferId);
12333
+ return;
12334
+ }
12192
12335
  mine.set(op.sourceTokenId, token);
12193
12336
  }
12194
12337
  try {
@@ -12219,6 +12362,28 @@ async function classifyResumeFailure(deps, stores, transferId, err, report) {
12219
12362
  }
12220
12363
  report.failed.push(transferId);
12221
12364
  }
12365
+ function validateCoinPayload(c) {
12366
+ if (typeof c.coinId !== "string" || typeof c.amount !== "string") {
12367
+ throw new SphereError("intent payload is missing coinId/amount", "VALIDATION_ERROR");
12368
+ }
12369
+ const s = c.split;
12370
+ if (s !== void 0 && (typeof s.tokenId !== "string" || typeof s.splitAmount !== "string" || typeof s.remainderAmount !== "string")) {
12371
+ throw new SphereError("intent payload split spec is malformed", "VALIDATION_ERROR");
12372
+ }
12373
+ return { ...c, kind: "coin" };
12374
+ }
12375
+ function validateCoinlessPayload(p) {
12376
+ if (p.direct?.length !== 1 || typeof p.direct[0] !== "string" || p.direct[0] === "") {
12377
+ throw new SphereError(
12378
+ "token intent payload must name exactly one source token",
12379
+ "VALIDATION_ERROR"
12380
+ );
12381
+ }
12382
+ if (p.split !== void 0) {
12383
+ throw new SphereError("token intent payload cannot carry a split", "VALIDATION_ERROR");
12384
+ }
12385
+ return { ...p, kind: "coinless", direct: [p.direct[0]] };
12386
+ }
12222
12387
  function validatePayload(raw) {
12223
12388
  const p = raw !== null && typeof raw === "object" ? raw : null;
12224
12389
  if (p === null || p.v !== 2 || !Array.isArray(p.direct) || p.direct.some((t) => typeof t !== "string")) {
@@ -12227,14 +12392,17 @@ function validatePayload(raw) {
12227
12392
  "VALIDATION_ERROR"
12228
12393
  );
12229
12394
  }
12230
- if (typeof p.recipient !== "string" || typeof p.coinId !== "string" || typeof p.amount !== "string") {
12231
- throw new SphereError("intent payload is missing recipient/coinId/amount", "VALIDATION_ERROR");
12395
+ if (typeof p.recipient !== "string") {
12396
+ throw new SphereError("intent payload is missing recipient", "VALIDATION_ERROR");
12232
12397
  }
12233
- const s = p.split;
12234
- if (s !== void 0 && (typeof s.tokenId !== "string" || typeof s.splitAmount !== "string" || typeof s.remainderAmount !== "string")) {
12235
- throw new SphereError("intent payload split spec is malformed", "VALIDATION_ERROR");
12398
+ const kind = p.kind ?? "coin";
12399
+ if (kind !== "coin" && kind !== "coinless") {
12400
+ throw new SphereError(
12401
+ `unsupported intent kind '${String(kind)}' \u2014 not resumable by this client`,
12402
+ "VALIDATION_ERROR"
12403
+ );
12236
12404
  }
12237
- return p;
12405
+ return kind === "coinless" ? validateCoinlessPayload(p) : validateCoinPayload(p);
12238
12406
  }
12239
12407
 
12240
12408
  // modules/payments-v2/convergence.ts
@@ -12440,13 +12608,23 @@ async function openRow(entry, journal, decryptPayload) {
12440
12608
  transferId: entry.transferId,
12441
12609
  kind: "open",
12442
12610
  recipient: typeof payload?.recipient === "string" ? payload.recipient : "",
12443
- coinId: typeof payload?.coinId === "string" ? payload.coinId : "",
12444
- amount: typeof payload?.amount === "string" ? payload.amount : "",
12611
+ ...subject(payload),
12445
12612
  legs: { certified, total: Math.max(total, certified) },
12446
12613
  deliveryPending: journal.length > 0,
12447
12614
  createdAt: entry.createdAt
12448
12615
  };
12449
12616
  }
12617
+ function subject(payload) {
12618
+ if (payload?.kind === "coinless") {
12619
+ const tokenId = payload.direct?.[0];
12620
+ return { coinId: "", amount: "", ...typeof tokenId === "string" ? { tokenId } : {} };
12621
+ }
12622
+ const coin = payload;
12623
+ return {
12624
+ coinId: typeof coin?.coinId === "string" ? coin.coinId : "",
12625
+ amount: typeof coin?.amount === "string" ? coin.amount : ""
12626
+ };
12627
+ }
12450
12628
  function journalOnlyRow(transferId, entries) {
12451
12629
  const certified = distinctOpCount(entries);
12452
12630
  return {
@@ -12476,6 +12654,160 @@ function distinctOpCount(entries) {
12476
12654
  return new Set(entries.map((entry) => entry.opIndex)).size;
12477
12655
  }
12478
12656
 
12657
+ // modules/payments-v2/inventory/token-data.ts
12658
+ async function readTokenData(deps, tokenId) {
12659
+ if (deps.view.stateHashOf(tokenId) === void 0) {
12660
+ throw new SphereError(`Token ${tokenId} is not in inventory`, "VALIDATION_ERROR");
12661
+ }
12662
+ const bytes = (await deps.storagePort.getBlobs([tokenId])).get(tokenId);
12663
+ if (bytes === void 0) {
12664
+ throw new SphereError(`Token ${tokenId} has no blob in storage`, "STORAGE_ERROR");
12665
+ }
12666
+ return deps.engine.readTokenData(await deps.engine.decodeToken({ tokenId, token: bytes }));
12667
+ }
12668
+
12669
+ // modules/payments-v2/machine/payload.ts
12670
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
12671
+ return {
12672
+ v: 2,
12673
+ kind: "coin",
12674
+ recipient: recipientPubkey,
12675
+ coinId: request.coinId,
12676
+ amount: request.amount,
12677
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
12678
+ direct: [...spend.plan.direct],
12679
+ ...spend.plan.split !== void 0 ? {
12680
+ split: {
12681
+ tokenId: spend.plan.split.tokenId,
12682
+ splitAmount: spend.plan.split.splitAmount.toString(),
12683
+ remainderAmount: spend.plan.split.remainderAmount.toString()
12684
+ }
12685
+ } : {},
12686
+ spentStates
12687
+ };
12688
+ }
12689
+ function messageOf(err) {
12690
+ return err instanceof Error ? err.message : String(err);
12691
+ }
12692
+ function buildCoinlessPayload(recipientPubkey, request, spentStates) {
12693
+ return {
12694
+ v: 2,
12695
+ kind: "coinless",
12696
+ recipient: recipientPubkey,
12697
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
12698
+ direct: [request.tokenId],
12699
+ spentStates
12700
+ };
12701
+ }
12702
+
12703
+ // modules/payments-v2/mint-params.ts
12704
+ function mintParams(recipientPubkey, coinId, amount) {
12705
+ return { recipientPubkey, value: { assets: [{ coinId, amount }] } };
12706
+ }
12707
+
12708
+ // modules/payments-v2/mint.ts
12709
+ function runMintUnderJournal(deps, input) {
12710
+ return mintUnderJournal(deps, input);
12711
+ }
12712
+ async function mintUnderJournal(deps, input) {
12713
+ const { mintId, coinId, amount } = input;
12714
+ const engine = deps.engine;
12715
+ const entry = {
12716
+ mintId,
12717
+ coinId,
12718
+ amount: amount.toString(),
12719
+ tokenId: "",
12720
+ createdAt: deps.now()
12721
+ };
12722
+ await deps.mintJournal.upsert(entry);
12723
+ let token;
12724
+ try {
12725
+ token = await engine.mint(mintParams(deps.ownPubkeyBytes, coinId, amount), { transferId: mintId });
12726
+ } catch (err) {
12727
+ deps.armHeartbeat();
12728
+ return { success: false, error: messageOf(err) };
12729
+ }
12730
+ if (token.blob.tokenId !== entry.tokenId) {
12731
+ await deps.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
12732
+ }
12733
+ try {
12734
+ await finalizeMint(deps, mintId, token, coinId, amount.toString());
12735
+ } catch (err) {
12736
+ deps.armHeartbeat();
12737
+ return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
12738
+ }
12739
+ return { success: true, tokenId: token.blob.tokenId };
12740
+ }
12741
+ async function finalizeMint(deps, mintId, token, coinId, amount) {
12742
+ const engine = deps.engine;
12743
+ const bytes = token.blob.token;
12744
+ const digest = bytesToHex3(sha256(bytes));
12745
+ const keys = await deps.storagePort.uploadBlobs([{ sha256: digest, bytes }]);
12746
+ const key = keys.get(digest);
12747
+ if (key === void 0) {
12748
+ throw new SphereError(`no upload key returned for mint blob ${digest}`, "STORAGE_ERROR");
12749
+ }
12750
+ await deps.storagePort.applyDelta({
12751
+ transferId: mintId,
12752
+ spent: [],
12753
+ added: [{ tokenId: token.blob.tokenId, key }]
12754
+ });
12755
+ await deps.recordMint({
12756
+ tokenId: token.blob.tokenId,
12757
+ assets: [{ coinId, amount }]
12758
+ });
12759
+ await deps.mintJournal.removeByKey(mintId);
12760
+ deps.noteHeldState(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
12761
+ deps.refreshView();
12762
+ }
12763
+
12764
+ // modules/payments-v2/send-coinless.ts
12765
+ async function materializeCoinlessSpend(deps, input) {
12766
+ const { engine } = deps;
12767
+ const tokenId = input.request.tokenId;
12768
+ const bytes = (await deps.storagePort.getBlobs([tokenId])).get(tokenId);
12769
+ if (bytes === void 0) {
12770
+ throw new SphereError(`Selected source ${tokenId} has no blob in storage`, "STORAGE_ERROR");
12771
+ }
12772
+ const token = await engine.decodeToken({ tokenId, token: bytes });
12773
+ if (!isCoinlessEnvelope(token.valueEnvelope)) {
12774
+ throw new SphereError(
12775
+ `Token ${tokenId} carries coin value and cannot be sent with sendCoinless \u2014 use send()`,
12776
+ "VALIDATION_ERROR"
12777
+ );
12778
+ }
12779
+ const keys = await engine.deliveryKeys(bytes);
12780
+ const payload = buildCoinlessPayload(input.recipientPubkey, input.request, {
12781
+ [tokenId]: { local: keys.stateHash, protocol: keys.stateHash }
12782
+ });
12783
+ const plan = {
12784
+ transferId: input.transferId,
12785
+ recipientPubkey: input.recipientPubkey,
12786
+ payload,
12787
+ ops: buildOps(payload),
12788
+ sources: /* @__PURE__ */ new Map([[tokenId, token]]),
12789
+ ...input.request.memo !== void 0 ? { memo: input.request.memo } : {}
12790
+ };
12791
+ return { transferId: input.transferId, coinId: "", plan, sourceIds: input.sourceIds, sourceTokens: [] };
12792
+ }
12793
+
12794
+ // modules/payments-v2/send-errors.ts
12795
+ function partialize(err, run) {
12796
+ if (run.delivered.length === 0) return err;
12797
+ return new PartialSendConflictError(
12798
+ "Part of your payment was sent; the remaining amount could not be completed (see cause). The delivered portion is final \u2014 re-plan only the shortfall, never the full amount.",
12799
+ run.firstPartialId ?? "",
12800
+ [...run.delivered],
12801
+ run.amount,
12802
+ err
12803
+ );
12804
+ }
12805
+ function stampTransferId(err, transferId) {
12806
+ if (err instanceof SphereError && isPossiblyCommittedSendOutcome(err) && err.transferId === void 0) {
12807
+ err.transferId = transferId;
12808
+ }
12809
+ }
12810
+
12479
12811
  // modules/payments-v2/recipient.ts
12480
12812
  var ATTENTION_RECIPIENT_NETWORK_UNVERIFIED = "recipient:network-unverified";
12481
12813
  async function requireSameNetworkRecipient(deps, identifier) {
@@ -12524,11 +12856,6 @@ async function splitOpIndex(deps, envelope) {
12524
12856
  return Array.isArray(payload.direct) ? payload.direct.length : 0;
12525
12857
  }
12526
12858
 
12527
- // modules/payments-v2/mint-params.ts
12528
- function mintParams(recipientPubkey, coinId, amount) {
12529
- return { recipientPubkey, value: { assets: [{ coinId, amount }] } };
12530
- }
12531
-
12532
12859
  // modules/payments-v2/prewarm-cache.ts
12533
12860
  var PrewarmCache = class _PrewarmCache {
12534
12861
  entries = /* @__PURE__ */ new Map();
@@ -12702,30 +13029,15 @@ function transferringToken(tokenId, coinId, amount, registry, now) {
12702
13029
  });
12703
13030
  }
12704
13031
 
12705
- // modules/payments-v2/machine/payload.ts
12706
- function buildPayload(recipientPubkey, request, spend, spentStates) {
12707
- return {
12708
- v: 2,
12709
- recipient: recipientPubkey,
12710
- coinId: request.coinId,
12711
- amount: request.amount,
12712
- ...request.memo !== void 0 ? { memo: request.memo } : {},
12713
- direct: [...spend.plan.direct],
12714
- ...spend.plan.split !== void 0 ? {
12715
- split: {
12716
- tokenId: spend.plan.split.tokenId,
12717
- splitAmount: spend.plan.split.splitAmount.toString(),
12718
- remainderAmount: spend.plan.split.remainderAmount.toString()
12719
- }
12720
- } : {},
12721
- spentStates
12722
- };
13032
+ // modules/payments-v2/history/History.ts
13033
+ function wireAssets(assets) {
13034
+ return assets.map((a) => ({ coinId: a.coinId, amount: a.amount }));
12723
13035
  }
12724
- function messageOf(err) {
12725
- return err instanceof Error ? err.message : String(err);
13036
+ function isPermanentReject(err) {
13037
+ const status = err?.status;
13038
+ if (typeof status !== "number") return false;
13039
+ return status >= 400 && status < 500 && status !== 408 && status !== 429;
12726
13040
  }
12727
-
12728
- // modules/payments-v2/history/History.ts
12729
13041
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
12730
13042
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
12731
13043
  var History = class {
@@ -12750,7 +13062,7 @@ var History = class {
12750
13062
  dedupKey: input.transferId,
12751
13063
  id: (this.deps.newId ?? randomUUID)(),
12752
13064
  type: "SENT",
12753
- assets: [{ coinId: input.coinId, amount: input.amount }],
13065
+ assets: wireAssets(input.assets),
12754
13066
  ts: this.ts(input.timestamp),
12755
13067
  transferId: input.transferId,
12756
13068
  ...this.wireTokenId(input.tokenId),
@@ -12763,7 +13075,7 @@ var History = class {
12763
13075
  dedupKey: `RECEIVED:${input.tokenId.toLowerCase()}:${input.stateHash.toLowerCase()}`,
12764
13076
  id: (this.deps.newId ?? randomUUID)(),
12765
13077
  type: "RECEIVED",
12766
- assets: [{ coinId: input.coinId, amount: input.amount }],
13078
+ assets: wireAssets(input.assets),
12767
13079
  ts: this.ts(input.timestamp),
12768
13080
  ...input.transferId !== void 0 ? { transferId: input.transferId } : {},
12769
13081
  ...this.wireTokenId(input.tokenId),
@@ -12776,7 +13088,7 @@ var History = class {
12776
13088
  dedupKey: `MINT:${input.tokenId.toLowerCase()}`,
12777
13089
  id: (this.deps.newId ?? randomUUID)(),
12778
13090
  type: "MINT",
12779
- assets: [{ coinId: input.coinId, amount: input.amount }],
13091
+ assets: wireAssets(input.assets),
12780
13092
  ts: this.ts(input.timestamp),
12781
13093
  ...this.wireTokenId(input.tokenId)
12782
13094
  }));
@@ -12787,7 +13099,10 @@ var History = class {
12787
13099
  try {
12788
13100
  await this.deps.client.postHistory([record]);
12789
13101
  } catch (err) {
12790
- this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
13102
+ this.deps.log?.(
13103
+ isPermanentReject(err) ? "history POST REJECTED \u2014 the record shape is refused; no retry can fix it" : "history POST failed (dedupKey makes retry safe)",
13104
+ err
13105
+ );
12791
13106
  return;
12792
13107
  }
12793
13108
  this.deps.emit("history:updated", this.toEntry(record));
@@ -12854,6 +13169,13 @@ var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
12854
13169
  function stateKey(tokenId, stateHash) {
12855
13170
  return `${tokenId}:${stateHash}`;
12856
13171
  }
13172
+ function unchanged(prev, item, tokenType) {
13173
+ return prev.seq === item.seq && prev.status === item.status && prev.stateHash === item.stateHash && tokenType === prev.tokenType;
13174
+ }
13175
+ function isCoinless(item, prev) {
13176
+ if (item.status !== "active") return prev?.coinless ?? false;
13177
+ return (item.assets?.length ?? 0) === 0;
13178
+ }
12857
13179
  var InventoryView = class {
12858
13180
  constructor(deps) {
12859
13181
  this.deps = deps;
@@ -12947,14 +13269,26 @@ var InventoryView = class {
12947
13269
  pool(coinId) {
12948
13270
  const out = [];
12949
13271
  for (const [tokenId, entry] of this.mirror) {
12950
- if (entry.status !== "active") continue;
12951
- if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) continue;
12952
- if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
13272
+ if (!this.isSpendable(tokenId, entry)) continue;
12953
13273
  const asset = entry.assets.find((a) => a.coinId === coinId);
12954
13274
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
12955
13275
  }
12956
13276
  return out;
12957
13277
  }
13278
+ /**
13279
+ * The eligibility gates every spend shares, coin or token — ONE definition, so a
13280
+ * single probe covers both verbs and neither can drift from the other.
13281
+ */
13282
+ isSpendable(tokenId, entry) {
13283
+ if (entry.status !== "active") return false;
13284
+ if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) return false;
13285
+ return !this.suspected.has(stateKey(tokenId, entry.stateHash));
13286
+ }
13287
+ /** #777: is this NAMED token a spendable COINLESS holding? Never a coin source. */
13288
+ spendableCoinless(tokenId) {
13289
+ const entry = this.mirror.get(tokenId);
13290
+ return entry !== void 0 && entry.coinless && this.isSpendable(tokenId, entry);
13291
+ }
12958
13292
  /**
12959
13293
  * Current mirrored state of an ACTIVE token, or undefined when it is unknown
12960
13294
  * or tombstoned. Exists so a cached blob can be state-scoped (F6): a blob is
@@ -12980,6 +13314,29 @@ var InventoryView = class {
12980
13314
  }
12981
13315
  return out;
12982
13316
  }
13317
+ /**
13318
+ * Coinless holdings (#777) — DISJOINT from tokens(): an entry is coinless
13319
+ * exactly when it is not, so no mirror row can appear in both reads.
13320
+ */
13321
+ coinless(registry) {
13322
+ const out = [];
13323
+ for (const [tokenId, entry] of this.mirror) {
13324
+ if (entry.status !== "active" || !entry.coinless) continue;
13325
+ const meta = entry.tokenType !== void 0 ? registry.getTypeMeta?.(entry.tokenType) : null;
13326
+ out.push({
13327
+ tokenId,
13328
+ ...entry.tokenType !== void 0 ? { tokenType: entry.tokenType } : {},
13329
+ ...meta ? { name: meta.name } : {},
13330
+ ...meta?.iconUrl != null ? { iconUrl: meta.iconUrl } : {},
13331
+ stateHash: entry.stateHash,
13332
+ transferring: this.held(tokenId),
13333
+ ...this.suspected.has(stateKey(tokenId, entry.stateHash)) ? { suspectedSpent: true } : {},
13334
+ createdAt: entry.createdAt,
13335
+ updatedAt: entry.updatedAt
13336
+ });
13337
+ }
13338
+ return out;
13339
+ }
12983
13340
  async assets(registry, price) {
12984
13341
  const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
12985
13342
  if (!price || raw.length === 0) return raw;
@@ -13051,14 +13408,19 @@ var InventoryView = class {
13051
13408
  applyOne(item, now) {
13052
13409
  const prev = this.mirror.get(item.tokenId);
13053
13410
  if (prev && item.seq < prev.seq) return false;
13054
- if (prev && prev.seq === item.seq && prev.status === item.status && prev.stateHash === item.stateHash) {
13055
- return false;
13056
- }
13411
+ const tokenType = item.tokenType ?? prev?.tokenType;
13412
+ if (prev && unchanged(prev, item, tokenType)) return false;
13057
13413
  this.mirror.set(item.tokenId, {
13058
13414
  stateHash: item.stateHash,
13059
13415
  seq: item.seq,
13060
13416
  status: item.status,
13061
- assets: item.assets ?? prev?.assets ?? [],
13417
+ // ONLY a tombstone inherits: it omits assets for an unrelated reason and
13418
+ // recoverRemoved must know the amount it restores. An ACTIVE row that omits
13419
+ // them is stating coinlessness, and inheriting there would keep stale assets
13420
+ // in tokens() while `coinless` is true — the row in BOTH reads (§16).
13421
+ assets: item.assets ?? (item.status === "removed" ? prev?.assets ?? [] : []),
13422
+ coinless: isCoinless(item, prev),
13423
+ ...tokenType !== void 0 ? { tokenType } : {},
13062
13424
  createdAt: prev?.createdAt ?? now,
13063
13425
  updatedAt: now
13064
13426
  });
@@ -13294,7 +13656,12 @@ async function screen(deps, engine, entry) {
13294
13656
  }
13295
13657
  return {
13296
13658
  kind: "accept",
13297
- record: { tokenId: keys.tokenId, stateHash: keys.stateHash, assets: toAssetAmounts(token) }
13659
+ record: {
13660
+ tokenId: keys.tokenId,
13661
+ stateHash: keys.stateHash,
13662
+ assets: toAssetAmounts(token),
13663
+ ...isCoinlessEnvelope2(token.valueEnvelope) ? { tokenType: token.tokenType } : {}
13664
+ }
13298
13665
  };
13299
13666
  }
13300
13667
  async function announce(deps, entry, record) {
@@ -13305,6 +13672,7 @@ async function announce(deps, entry, record) {
13305
13672
  tokenId: record.tokenId,
13306
13673
  stateHash: record.stateHash,
13307
13674
  assets: record.assets,
13675
+ ...record.tokenType !== void 0 ? { tokenType: record.tokenType } : {},
13308
13676
  ...entry.senderPubkey !== void 0 ? { senderPubkey: entry.senderPubkey } : {},
13309
13677
  ...entry.senderNametag !== void 0 ? { senderNametag: entry.senderNametag } : {},
13310
13678
  ...entry.memo !== void 0 ? { memo: entry.memo } : {},
@@ -13318,6 +13686,19 @@ async function announce(deps, entry, record) {
13318
13686
  senderPubkey: entry.senderPubkey ?? "",
13319
13687
  ...entry.senderNametag !== void 0 ? { senderNametag: entry.senderNametag } : {},
13320
13688
  tokens: record.assets.map((asset) => toUiToken(record.tokenId, asset, deps.registry, receivedAt)),
13689
+ // #777: named here rather than mapped from assets, which announced an EMPTY list.
13690
+ ...record.tokenType !== void 0 ? {
13691
+ coinless: [
13692
+ {
13693
+ tokenId: record.tokenId,
13694
+ tokenType: record.tokenType,
13695
+ stateHash: record.stateHash,
13696
+ transferring: false,
13697
+ createdAt: receivedAt,
13698
+ updatedAt: receivedAt
13699
+ }
13700
+ ]
13701
+ } : {},
13321
13702
  ...entry.memo !== void 0 ? { memo: entry.memo } : {},
13322
13703
  receivedAt
13323
13704
  };
@@ -13416,6 +13797,9 @@ function claimAck(entry) {
13416
13797
  function rejectAck(entry, reason) {
13417
13798
  return { deliveryId: entry.deliveryId, disposition: "rejected", reason, cursor: entry.cursor };
13418
13799
  }
13800
+ function isCoinlessEnvelope2(envelope) {
13801
+ return envelope.startsWith("none_");
13802
+ }
13419
13803
  function toAssetAmounts(token) {
13420
13804
  return (token.value?.assets ?? []).map((asset) => ({
13421
13805
  coinId: asset.coinId,
@@ -13983,6 +14367,37 @@ var SpendQueue = class {
13983
14367
  }
13984
14368
  return { kind: "queued", settled: this.enqueue(reservationId, request.coinId, amount) };
13985
14369
  }
14370
+ /**
14371
+ * #777: reserve a NAMED coinless source. Never queues — there is nothing to wait
14372
+ * for. A coin plan waits because some OTHER combination may free up; a named
14373
+ * token is either free now or held by a reservation that only its own transfer
14374
+ * can release, so queueing would block until a timeout on a spend that cannot
14375
+ * become possible.
14376
+ */
14377
+ planCoinless(reservationId, tokenId) {
14378
+ if (this.destroyed) {
14379
+ throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
14380
+ }
14381
+ const unproven = this.deps.ledger.unprovenReason();
14382
+ if (unproven !== null) {
14383
+ throw new SphereError(`Cannot spend yet: ${unproven}`, "SEND_SYNC_PENDING");
14384
+ }
14385
+ if (this.deps.spendableCoinless?.(tokenId) !== true) {
14386
+ throw new SphereError(
14387
+ `Token ${tokenId} is not a spendable coinless holding`,
14388
+ "VALIDATION_ERROR"
14389
+ );
14390
+ }
14391
+ const holder = this.deps.ledger.holderOf(tokenId);
14392
+ if (holder !== void 0) {
14393
+ throw new SphereError(
14394
+ `Token ${tokenId} is already reserved by transfer ${holder}`,
14395
+ "VALIDATION_ERROR"
14396
+ );
14397
+ }
14398
+ this.deps.ledger.reserve(reservationId, [{ tokenId }]);
14399
+ return { reservationId, plan: { direct: [tokenId] } };
14400
+ }
13986
14401
  /**
13987
14402
  * The sources `plan()` WOULD pick right now, reserving nothing and queueing
13988
14403
  * nothing — a read-only probe used to warm blobs while the user is still
@@ -14172,6 +14587,7 @@ function composeFacadeParts(deps, hooks) {
14172
14587
  const queue = new SpendQueue({
14173
14588
  ledger,
14174
14589
  getPool: (coinId) => view.pool(coinId),
14590
+ spendableCoinless: (tokenId) => view.spendableCoinless(tokenId),
14175
14591
  ...deps.workBudget !== void 0 ? { workBudget: deps.workBudget } : {}
14176
14592
  });
14177
14593
  const historyStore = new History({
@@ -14255,10 +14671,9 @@ function buildMachineDeps(deps, hooks, historyStore) {
14255
14671
  recordHistory: async ({ transferId, payload, committedAmount }) => {
14256
14672
  await historyStore.recordSent({
14257
14673
  transferId,
14258
- coinId: payload.coinId,
14259
- // §5.9: the SETTLED amount (machine-computed from the certified
14260
- // recipient blobs), never payload.amount — the plan.
14261
- amount: committedAmount,
14674
+ // §5.9: the SETTLED amount, never payload.amount — the plan. A token
14675
+ // spend moved no coin: `assets: []` + tokenId (wallet-api#151 / §10).
14676
+ ...payload.kind === "coinless" ? { assets: [], tokenId: payload.direct[0] } : { assets: [{ coinId: payload.coinId, amount: committedAmount }] },
14262
14677
  recipientPubkey: payload.recipient,
14263
14678
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
14264
14679
  });
@@ -14293,12 +14708,10 @@ function buildReceive(deps, hooks, historyStore, heldStates, refreshView) {
14293
14708
  });
14294
14709
  }
14295
14710
  async function recordReceived(historyStore, record) {
14296
- const first = record.assets[0];
14297
14711
  await historyStore.recordReceived({
14298
14712
  tokenId: record.tokenId,
14299
14713
  stateHash: record.stateHash,
14300
- coinId: first?.coinId ?? "",
14301
- amount: first?.amount ?? "0",
14714
+ assets: record.assets,
14302
14715
  ...record.senderPubkey !== void 0 ? { senderPubkey: record.senderPubkey } : {},
14303
14716
  ...record.senderNametag !== void 0 ? { senderNametag: record.senderNametag } : {},
14304
14717
  ...record.memo !== void 0 ? { memo: record.memo } : {},
@@ -14446,6 +14859,13 @@ var PaymentsFacade = class {
14446
14859
  tokens(filter) {
14447
14860
  return this.view.tokens(this.deps.registry, filter);
14448
14861
  }
14862
+ coinless() {
14863
+ return this.view.coinless(this.deps.registry);
14864
+ }
14865
+ tokenData(tokenId) {
14866
+ const deps = { engine: this.engine(), view: this.view, storagePort: this.deps.storagePort };
14867
+ return readTokenData(deps, tokenId);
14868
+ }
14449
14869
  history(page) {
14450
14870
  return this.historyStore.page(page ?? {});
14451
14871
  }
@@ -14470,6 +14890,9 @@ var PaymentsFacade = class {
14470
14890
  send(request) {
14471
14891
  return this.track(this.sendOutcome(request));
14472
14892
  }
14893
+ sendCoinless(request) {
14894
+ return this.track(this.sendCoinlessOutcome(request));
14895
+ }
14473
14896
  async receive() {
14474
14897
  const transfers = await this.track(this.receiveLoop.drainOnce());
14475
14898
  return { transfers };
@@ -14500,12 +14923,32 @@ var PaymentsFacade = class {
14500
14923
  nowMs() {
14501
14924
  return (this.deps.now ?? Date.now)();
14502
14925
  }
14926
+ mintDeps() {
14927
+ return {
14928
+ engine: this.engine(),
14929
+ mintJournal: this.machineStores.mintJournal,
14930
+ storagePort: this.deps.storagePort,
14931
+ recordMint: (input) => this.historyStore.recordMint(input),
14932
+ armHeartbeat: () => this.heartbeat.arm(),
14933
+ noteHeldState: (tokenId, stateHash) => void this.heldStates.set(tokenId, stateHash),
14934
+ refreshView: () => this.trackTail(this.view.delta()),
14935
+ ownPubkeyBytes: this.ownPubkeyBytes,
14936
+ now: () => this.nowMs()
14937
+ };
14938
+ }
14503
14939
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
14504
14940
  /** The ONE place a send() outcome is shaped: success emits in finishSend, a
14505
14941
  * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
14506
- async sendOutcome(request) {
14942
+ sendOutcome(request) {
14943
+ return this.runJob({ kind: "coin", request }, request.amount);
14944
+ }
14945
+ /** A token spend has no amount; '0' keeps the shortfall arithmetic total-free. */
14946
+ sendCoinlessOutcome(request) {
14947
+ return this.runJob({ kind: "coinless", request }, "0");
14948
+ }
14949
+ async runJob(job, amount) {
14507
14950
  const run = {
14508
- amount: request.amount,
14951
+ amount,
14509
14952
  delivered: [],
14510
14953
  sentTokens: [],
14511
14954
  tokenTransfers: [],
@@ -14515,7 +14958,7 @@ var PaymentsFacade = class {
14515
14958
  lastTransferId: ""
14516
14959
  };
14517
14960
  try {
14518
- return await this.sendWithPolicy(request, run);
14961
+ return await this.sendWithPolicy(job, run);
14519
14962
  } catch (err) {
14520
14963
  this.emitCleanFailure(err, run.lastTransferId);
14521
14964
  throw err;
@@ -14535,19 +14978,19 @@ var PaymentsFacade = class {
14535
14978
  };
14536
14979
  this.deps.emit("transfer:updated", failed);
14537
14980
  }
14538
- async sendWithPolicy(request, run) {
14539
- const recipient = await requireSameNetworkRecipient(this.deps, request.recipient);
14981
+ async sendWithPolicy(job, run) {
14982
+ const recipient = await requireSameNetworkRecipient(this.deps, job.request.recipient);
14540
14983
  for (let attempt = 0; ; attempt++) {
14541
14984
  let ctx;
14542
14985
  try {
14543
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
14986
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, job, run);
14544
14987
  } catch (err) {
14545
- throw this.partialize(err, run);
14988
+ throw partialize(err, run);
14546
14989
  }
14547
14990
  const disposition = await this.runAttempt(ctx);
14548
- if (disposition.kind === "rethrow") throw this.partialize(disposition.error, run);
14991
+ if (disposition.kind === "rethrow") throw partialize(disposition.error, run);
14549
14992
  if (disposition.kind === "retry-full") {
14550
- if (attempt >= MAX_RESELECT) throw this.partialize(disposition.error, run);
14993
+ if (job.kind === "coinless" || attempt >= MAX_RESELECT) throw partialize(disposition.error, run);
14551
14994
  continue;
14552
14995
  }
14553
14996
  if (disposition.kind === "success") {
@@ -14603,7 +15046,7 @@ var PaymentsFacade = class {
14603
15046
  async disposeFailedAttempt(ctx, err) {
14604
15047
  if (isPossiblyCommittedSendOutcome(err)) {
14605
15048
  this.settleKeepOpen(ctx);
14606
- this.stampTransferId(err, ctx.transferId);
15049
+ stampTransferId(err, ctx.transferId);
14607
15050
  return { kind: "rethrow", error: err };
14608
15051
  }
14609
15052
  const backstop = await this.machineStores.backstop.getByKey(ctx.transferId);
@@ -14637,7 +15080,7 @@ var PaymentsFacade = class {
14637
15080
  async disposeConvergeFailure(ctx, err) {
14638
15081
  if (isPossiblyCommittedSendOutcome(err)) {
14639
15082
  this.settleKeepOpen(ctx);
14640
- this.stampTransferId(err, ctx.transferId);
15083
+ stampTransferId(err, ctx.transferId);
14641
15084
  return { kind: "rethrow", error: err };
14642
15085
  }
14643
15086
  if (classifyError(err) === "conflict") {
@@ -14648,9 +15091,23 @@ var PaymentsFacade = class {
14648
15091
  }
14649
15092
  return { kind: "rethrow", error: err };
14650
15093
  }
14651
- async planAndMaterialize(recipientPubkey, request, run) {
15094
+ async planAndMaterialize(recipientPubkey, job, run) {
14652
15095
  const transferId = this.newId();
14653
15096
  run.lastTransferId = transferId;
15097
+ if (job.kind === "coinless") {
15098
+ const spend2 = this.queue.planCoinless(transferId, job.request.tokenId);
15099
+ const sourceIds2 = this.markPlanned(transferId, "", spend2);
15100
+ try {
15101
+ return await materializeCoinlessSpend(
15102
+ { engine: this.engine(), storagePort: this.deps.storagePort },
15103
+ { transferId, recipientPubkey, request: job.request, sourceIds: sourceIds2 }
15104
+ );
15105
+ } catch (err) {
15106
+ this.settleFailure(transferId, "", sourceIds2);
15107
+ throw err;
15108
+ }
15109
+ }
15110
+ const request = { ...job.request, amount: run.amount };
14654
15111
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
14655
15112
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
14656
15113
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -14784,22 +15241,6 @@ var PaymentsFacade = class {
14784
15241
  return result;
14785
15242
  }
14786
15243
  /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
14787
- partialize(err, run) {
14788
- if (run.delivered.length === 0) return err;
14789
- return new PartialSendConflictError(
14790
- "Part of your payment was sent; the remaining amount could not be completed (see cause). The delivered portion is final \u2014 re-plan only the shortfall, never the full amount.",
14791
- run.firstPartialId ?? "",
14792
- run.delivered,
14793
- run.amount,
14794
- err
14795
- );
14796
- }
14797
- /** #441: possibly-committed errors must carry the transferId for the settling journal. */
14798
- stampTransferId(err, transferId) {
14799
- if (err instanceof SphereError && isPossiblyCommittedSendOutcome(err) && err.transferId === void 0) {
14800
- err.transferId = transferId;
14801
- }
14802
- }
14803
15244
  async softAbort(transferId) {
14804
15245
  try {
14805
15246
  await this.deps.client.abortIntent(transferId);
@@ -14815,57 +15256,11 @@ var PaymentsFacade = class {
14815
15256
  const mintId = this.newId();
14816
15257
  this.activeMoneyOps.add(mintId);
14817
15258
  try {
14818
- return await this.mintUnderJournal(mintId, coinId, amount);
15259
+ return await runMintUnderJournal(this.mintDeps(), { mintId, coinId, amount });
14819
15260
  } finally {
14820
15261
  this.activeMoneyOps.delete(mintId);
14821
15262
  }
14822
15263
  }
14823
- async mintUnderJournal(mintId, coinId, amount) {
14824
- const engine = this.engine();
14825
- const entry = {
14826
- mintId,
14827
- coinId,
14828
- amount: amount.toString(),
14829
- tokenId: "",
14830
- createdAt: this.nowMs()
14831
- };
14832
- await this.machineStores.mintJournal.upsert(entry);
14833
- let token;
14834
- try {
14835
- token = await engine.mint(mintParams(this.ownPubkeyBytes, coinId, amount), { transferId: mintId });
14836
- } catch (err) {
14837
- this.heartbeat.arm();
14838
- return { success: false, error: messageOf(err) };
14839
- }
14840
- if (token.blob.tokenId !== entry.tokenId) {
14841
- await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
14842
- }
14843
- try {
14844
- await this.finalizeMint(engine, mintId, token, coinId, amount.toString());
14845
- } catch (err) {
14846
- this.heartbeat.arm();
14847
- return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
14848
- }
14849
- return { success: true, tokenId: token.blob.tokenId };
14850
- }
14851
- async finalizeMint(engine, mintId, token, coinId, amount) {
14852
- const bytes = token.blob.token;
14853
- const digest = bytesToHex3(sha256(bytes));
14854
- const keys = await this.deps.storagePort.uploadBlobs([{ sha256: digest, bytes }]);
14855
- const key = keys.get(digest);
14856
- if (key === void 0) {
14857
- throw new SphereError(`no upload key returned for mint blob ${digest}`, "STORAGE_ERROR");
14858
- }
14859
- await this.deps.storagePort.applyDelta({
14860
- transferId: mintId,
14861
- spent: [],
14862
- added: [{ tokenId: token.blob.tokenId, key }]
14863
- });
14864
- await this.historyStore.recordMint({ tokenId: token.blob.tokenId, coinId, amount });
14865
- await this.machineStores.mintJournal.removeByKey(mintId);
14866
- this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
14867
- this.trackTail(this.view.delta());
14868
- }
14869
15264
  /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */
14870
15265
  async replayMints() {
14871
15266
  let resolved = 0;
@@ -14897,7 +15292,7 @@ var PaymentsFacade = class {
14897
15292
  if (token.blob.tokenId !== entry.tokenId) {
14898
15293
  await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
14899
15294
  }
14900
- await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount);
15295
+ await finalizeMint(this.mintDeps(), entry.mintId, token, entry.coinId, entry.amount);
14901
15296
  return true;
14902
15297
  }
14903
15298
  async tokenInServerInventory(tokenId) {