@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
@@ -8212,6 +8212,8 @@ var FETCH_TIMEOUT_MS = 1e4;
8212
8212
  var TokenRegistry = class _TokenRegistry {
8213
8213
  static instance = null;
8214
8214
  definitionsById;
8215
+ /** Non-fungible definitions keyed by TOKEN TYPE — a separate namespace (#147). */
8216
+ definitionsByType;
8215
8217
  definitionsBySymbol;
8216
8218
  definitionsByName;
8217
8219
  // Remote refresh state
@@ -8230,6 +8232,7 @@ var TokenRegistry = class _TokenRegistry {
8230
8232
  inFlight = null;
8231
8233
  constructor() {
8232
8234
  this.definitionsById = /* @__PURE__ */ new Map();
8235
+ this.definitionsByType = /* @__PURE__ */ new Map();
8233
8236
  this.definitionsBySymbol = /* @__PURE__ */ new Map();
8234
8237
  this.definitionsByName = /* @__PURE__ */ new Map();
8235
8238
  }
@@ -8457,9 +8460,11 @@ var TokenRegistry = class _TokenRegistry {
8457
8460
  this.definitionsById.clear();
8458
8461
  this.definitionsBySymbol.clear();
8459
8462
  this.definitionsByName.clear();
8463
+ this.definitionsByType.clear();
8460
8464
  for (const def of definitions) {
8461
8465
  const idLower = def.id.toLowerCase();
8462
8466
  this.definitionsById.set(idLower, def);
8467
+ if (def.assetKind === "non-fungible") this.definitionsByType.set(idLower, def);
8463
8468
  if (def.symbol) {
8464
8469
  this.definitionsBySymbol.set(def.symbol.toUpperCase(), def);
8465
8470
  }
@@ -8673,6 +8678,20 @@ var TokenRegistry = class _TokenRegistry {
8673
8678
  getAllDefinitions() {
8674
8679
  return Array.from(this.definitionsById.values());
8675
8680
  }
8681
+ /**
8682
+ * Definition for a coinless token's genesis TOKEN TYPE (#777). Never falls back
8683
+ * to `getDefinition`: a type and a coin id live in different namespaces, so a
8684
+ * type that collided with a coin id would otherwise render as that coin.
8685
+ */
8686
+ getTypeMeta(tokenType) {
8687
+ const def = this.getTypeDefinition(tokenType);
8688
+ if (!def) return null;
8689
+ return { name: def.name, iconUrl: def.icons?.[0]?.url ?? null };
8690
+ }
8691
+ getTypeDefinition(tokenType) {
8692
+ if (!tokenType) return void 0;
8693
+ return this.definitionsByType.get(tokenType.toLowerCase());
8694
+ }
8676
8695
  /**
8677
8696
  * Get all fungible token definitions
8678
8697
  * @returns Array of fungible token definitions
@@ -9508,6 +9527,8 @@ var import_DataHasherFactory = require("@unicitylabs/state-transition-sdk/lib/cr
9508
9527
  var import_CborSerializer = require("@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborSerializer.js");
9509
9528
  var import_CborDeserializer = require("@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborDeserializer.js");
9510
9529
  var import_CborError = require("@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborError.js");
9530
+ var import_CborReader = require("@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborReader.js");
9531
+ var import_MajorType = require("@unicitylabs/state-transition-sdk/lib/serialization/cbor/MajorType.js");
9511
9532
  var import_Asset = require("@unicitylabs/state-transition-sdk/lib/payment/asset/Asset.js");
9512
9533
  var import_AssetId = require("@unicitylabs/state-transition-sdk/lib/payment/asset/AssetId.js");
9513
9534
  var import_PaymentAssetCollection = require("@unicitylabs/state-transition-sdk/lib/payment/asset/PaymentAssetCollection.js");
@@ -9920,6 +9941,126 @@ async function burntTokenFromCheckpoint(deps, storedBytes, reDerivedBurnTx, sour
9920
9941
  return burntToken;
9921
9942
  }
9922
9943
 
9944
+ // token-engine/value-envelope.ts
9945
+ function isCoinlessEnvelope(envelope) {
9946
+ return envelope.startsWith("none_");
9947
+ }
9948
+ var SELF_DESCRIBED_CBOR_TAG = 55799n;
9949
+ var CBOR_MAJOR_TYPE_MASK = 224;
9950
+ var describe = (error) => error instanceof Error ? error.message : String(error);
9951
+ var invalid = (message) => new SphereError(message, "VALIDATION_ERROR");
9952
+ function majorTypeOf(bytes) {
9953
+ const first = bytes.at(0);
9954
+ return first === void 0 ? null : first & CBOR_MAJOR_TYPE_MASK;
9955
+ }
9956
+ function headTagNumber(bytes) {
9957
+ try {
9958
+ return new import_CborReader.CborReader(bytes).readLength(import_MajorType.MajorType.TAG);
9959
+ } catch {
9960
+ return null;
9961
+ }
9962
+ }
9963
+ function isAssetShaped(item) {
9964
+ try {
9965
+ return import_CborDeserializer.CborDeserializer.decodeArray(item, 2).every(
9966
+ (field) => majorTypeOf(field) === import_MajorType.MajorType.BYTE_STRING
9967
+ );
9968
+ } catch {
9969
+ return false;
9970
+ }
9971
+ }
9972
+ function arrayItemsOrNull(bytes) {
9973
+ try {
9974
+ return import_CborDeserializer.CborDeserializer.decodeArray(bytes);
9975
+ } catch {
9976
+ return null;
9977
+ }
9978
+ }
9979
+ function decodeSphere(genesisData) {
9980
+ try {
9981
+ return SpherePaymentData.fromCBOR(genesisData).toValue();
9982
+ } catch (error) {
9983
+ throw invalid(`Failed to decode token payment data: ${describe(error)}`);
9984
+ }
9985
+ }
9986
+ function taggedItemBody(genesisData) {
9987
+ try {
9988
+ return import_CborDeserializer.CborDeserializer.decodeTag(genesisData).data;
9989
+ } catch (error) {
9990
+ throw invalid(
9991
+ `Token value payload is a CBOR tag whose item is malformed or carries trailing bytes: ${describe(error)}`
9992
+ );
9993
+ }
9994
+ }
9995
+ function selfDescribedBodyCarriesValue(body) {
9996
+ const major = majorTypeOf(body);
9997
+ if (major === import_MajorType.MajorType.TAG) {
9998
+ const inner = headTagNumber(body);
9999
+ return inner === SpherePaymentData.CBOR_TAG || inner === SELF_DESCRIBED_CBOR_TAG;
10000
+ }
10001
+ if (major === import_MajorType.MajorType.ARRAY) {
10002
+ const items = arrayItemsOrNull(body);
10003
+ return items !== null && items.some(isAssetShaped);
10004
+ }
10005
+ return false;
10006
+ }
10007
+ function classifyTagged(genesisData) {
10008
+ const tag = headTagNumber(genesisData);
10009
+ if (tag === null) {
10010
+ throw invalid(
10011
+ "Token value payload has an unreadable or non-canonically encoded CBOR tag head \u2014 it could be a malformed SpherePaymentData envelope"
10012
+ );
10013
+ }
10014
+ if (tag === SpherePaymentData.CBOR_TAG) {
10015
+ return { envelope: "sphere", value: decodeSphere(genesisData) };
10016
+ }
10017
+ const body = taggedItemBody(genesisData);
10018
+ if (tag === SELF_DESCRIBED_CBOR_TAG && selfDescribedBodyCarriesValue(body)) {
10019
+ throw invalid(
10020
+ "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"
10021
+ );
10022
+ }
10023
+ return { envelope: "none_tag", value: null };
10024
+ }
10025
+ function classifyArray(genesisData) {
10026
+ const items = arrayItemsOrNull(genesisData);
10027
+ if (items === null) {
10028
+ throw invalid("Token value payload is not PaymentAssetCollection: malformed CBOR array");
10029
+ }
10030
+ return items.some(isAssetShaped) ? { envelope: "bare_collection", value: null } : { envelope: "none_other", value: null };
10031
+ }
10032
+ function classifyValueEnvelope(genesisData) {
10033
+ if (genesisData === null) return { envelope: "none_absent", value: null };
10034
+ const major = majorTypeOf(genesisData);
10035
+ if (major === null) return { envelope: "none_absent", value: null };
10036
+ if (major === import_MajorType.MajorType.TAG) return classifyTagged(genesisData);
10037
+ if (major === import_MajorType.MajorType.ARRAY) return classifyArray(genesisData);
10038
+ return { envelope: "none_other", value: null };
10039
+ }
10040
+ function wrapToken(sdkToken) {
10041
+ const { envelope, value } = classifyValueEnvelope(sdkToken.genesis.data);
10042
+ const blob = {
10043
+ tokenId: import_HexConverter.HexConverter.encode(sdkToken.id.bytes),
10044
+ token: sdkToken.toCBOR()
10045
+ };
10046
+ return {
10047
+ sdkToken,
10048
+ blob,
10049
+ value,
10050
+ valueEnvelope: envelope,
10051
+ tokenType: import_HexConverter.HexConverter.encode(sdkToken.type.bytes)
10052
+ };
10053
+ }
10054
+ function assertMintableData(data) {
10055
+ try {
10056
+ classifyValueEnvelope(data);
10057
+ } catch (error) {
10058
+ throw invalid(
10059
+ `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.`
10060
+ );
10061
+ }
10062
+ }
10063
+
9923
10064
  // token-engine/SphereTokenEngine.ts
9924
10065
  var DEFAULT_PROOF_POLL_INTERVAL_MS = 300;
9925
10066
  var MAX_MINT_CONCURRENCY = 8;
@@ -9976,7 +10117,7 @@ var SphereTokenEngine = class {
9976
10117
  return sdkToken.latestTransaction.data;
9977
10118
  }
9978
10119
  const data = sdkToken.genesis.data;
9979
- if (data && this.isSpherePaymentData(data)) {
10120
+ if (data && classifyValueEnvelope(data).envelope === "sphere") {
9980
10121
  return SpherePaymentData.fromCBOR(data).memo;
9981
10122
  }
9982
10123
  return null;
@@ -10006,9 +10147,10 @@ var SphereTokenEngine = class {
10006
10147
  );
10007
10148
  const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
10008
10149
  const token = await import_Token.Token.mint(certified, this.deps.verificationContext);
10009
- return this.wrapToken(token);
10150
+ return wrapToken(token);
10010
10151
  }
10011
10152
  async mintDataToken(params, options) {
10153
+ assertMintableData(params.data);
10012
10154
  const recipient = import_SignaturePredicate.SignaturePredicate.create(params.recipientPubkey);
10013
10155
  const tokenType = params.tokenType ? new import_TokenType.TokenType(params.tokenType) : import_TokenType.TokenType.generate();
10014
10156
  const salt = params.salt ? import_TokenSalt.TokenSalt.fromBytes(params.salt) : import_TokenSalt.TokenSalt.generate();
@@ -10023,7 +10165,7 @@ var SphereTokenEngine = class {
10023
10165
  );
10024
10166
  const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
10025
10167
  const token = await import_Token.Token.mint(certified, this.deps.verificationContext);
10026
- return this.wrapToken(token);
10168
+ return wrapToken(token);
10027
10169
  }
10028
10170
  async transfer(params, options) {
10029
10171
  this.assertOwned(params.token);
@@ -10046,13 +10188,19 @@ var SphereTokenEngine = class {
10046
10188
  );
10047
10189
  const certified = await transferTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
10048
10190
  const transferred = await params.token.sdkToken.transfer(certified, this.deps.verificationContext);
10049
- return this.wrapToken(transferred);
10191
+ return wrapToken(transferred);
10050
10192
  }
10051
10193
  async split(params, options) {
10052
10194
  this.assertOwned(params.token);
10053
10195
  if (params.outputs.length === 0) {
10054
10196
  throw new SphereError("Split requires at least one output", "VALIDATION_ERROR");
10055
10197
  }
10198
+ if (params.token.value === null) {
10199
+ throw new SphereError(
10200
+ `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.`,
10201
+ "VALIDATION_ERROR"
10202
+ );
10203
+ }
10056
10204
  const transferId = this.resolveTransferId(options);
10057
10205
  const requests = params.outputs.map(
10058
10206
  (o, i) => import_SplitTokenRequest.SplitTokenRequest.create(
@@ -10174,7 +10322,7 @@ var SphereTokenEngine = class {
10174
10322
  const proof = await this.submitSplitMintLeg(certData, mintTx, options);
10175
10323
  const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
10176
10324
  const token = await import_Token.Token.mint(certified, this.deps.verificationContext);
10177
- return this.wrapToken(token);
10325
+ return wrapToken(token);
10178
10326
  }
10179
10327
  /**
10180
10328
  * Submit one split-mint leg. A `TRANSACTION_HASH_MISMATCH` on a mint leg is NOT a foreign spend
@@ -10232,7 +10380,7 @@ var SphereTokenEngine = class {
10232
10380
  "VALIDATION_ERROR"
10233
10381
  );
10234
10382
  }
10235
- return this.wrapToken(sdkToken);
10383
+ return wrapToken(sdkToken);
10236
10384
  }
10237
10385
  // ── internals ────────────────────────────────────────────────────────────────
10238
10386
  /**
@@ -10344,34 +10492,6 @@ var SphereTokenEngine = class {
10344
10492
  throw new SphereError("Cannot transfer a token not owned by this engine identity", "VALIDATION_ERROR");
10345
10493
  }
10346
10494
  }
10347
- /** Wrap an SDK token into a SphereToken: cache its blob (incl. stable tokenId) + decoded value. */
10348
- wrapToken(sdkToken) {
10349
- const data = sdkToken.genesis.data;
10350
- let value = null;
10351
- if (data && this.isSpherePaymentData(data)) {
10352
- try {
10353
- value = SpherePaymentData.fromCBOR(data).toValue();
10354
- } catch (err) {
10355
- throw new SphereError(
10356
- `Failed to decode token payment data: ${err instanceof Error ? err.message : String(err)}`,
10357
- "VALIDATION_ERROR"
10358
- );
10359
- }
10360
- }
10361
- const blob = {
10362
- tokenId: import_HexConverter.HexConverter.encode(sdkToken.id.bytes),
10363
- token: sdkToken.toCBOR()
10364
- };
10365
- return { sdkToken, blob, value };
10366
- }
10367
- /** True if the bytes are a SpherePaymentData envelope (value token) vs a raw data token. */
10368
- isSpherePaymentData(data) {
10369
- try {
10370
- return import_CborDeserializer.CborDeserializer.decodeTag(data).tag === SpherePaymentData.CBOR_TAG;
10371
- } catch {
10372
- return false;
10373
- }
10374
- }
10375
10495
  /**
10376
10496
  * Terminate the verification worker pool, if this engine was configured with
10377
10497
  * one. Idempotent — the pool's dispose only touches workers it spawned.
@@ -11767,6 +11887,14 @@ function createMachineStores(kv) {
11767
11887
  };
11768
11888
  }
11769
11889
 
11890
+ // modules/payments-v2/machine/payload-view.ts
11891
+ function splitOf(p) {
11892
+ return p.kind === "coin" ? p.split : void 0;
11893
+ }
11894
+ function coinIdOf(p) {
11895
+ return p.kind === "coin" ? p.coinId : void 0;
11896
+ }
11897
+
11770
11898
  // modules/payments-v2/machine/TransferMachine.ts
11771
11899
  var CERTIFY_WIDTH = 20;
11772
11900
  var KEEP_OPEN_CODES = /* @__PURE__ */ new Set([
@@ -11969,13 +12097,14 @@ var TransferMachine = class {
11969
12097
  const finished = await engine.transfer({ token: source, recipientPubkey }, opts);
11970
12098
  return { recipientBlob: finished.blob.token };
11971
12099
  }
11972
- const split = ctx.payload.split;
12100
+ const split = splitOf(ctx.payload);
12101
+ const splitCoinId = coinIdOf(ctx.payload);
11973
12102
  const { outputs } = await engine.split(
11974
12103
  {
11975
12104
  token: source,
11976
12105
  outputs: [
11977
- { recipientPubkey, coinId: ctx.payload.coinId, amount: BigInt(split.splitAmount) },
11978
- { recipientPubkey: this.deps.ownPubkey, coinId: ctx.payload.coinId, amount: BigInt(split.remainderAmount) }
12106
+ { recipientPubkey, coinId: splitCoinId, amount: BigInt(split.splitAmount) },
12107
+ { recipientPubkey: this.deps.ownPubkey, coinId: splitCoinId, amount: BigInt(split.remainderAmount) }
11979
12108
  ]
11980
12109
  },
11981
12110
  opts
@@ -12097,7 +12226,9 @@ var TransferMachine = class {
12097
12226
  const entry = {
12098
12227
  transferId: r.transferId,
12099
12228
  remainingAmount: undelivered.toString(),
12100
- coinId: r.payload.coinId,
12229
+ // '' only on a token-addressed intent, which is one leg: it either lands or
12230
+ // conflicts, so a shortfall row is unreachable there (settlePartial needs >1).
12231
+ coinId: coinIdOf(r.payload) ?? "",
12101
12232
  recipient: r.payload.recipient,
12102
12233
  committedTokenIds: committed.map((o) => o.op.sourceTokenId),
12103
12234
  createdAt: this.deps.now()
@@ -12123,7 +12254,8 @@ var TransferMachine = class {
12123
12254
  }
12124
12255
  async finishBookkeeping(engine, transferId, payload, phase, committed) {
12125
12256
  try {
12126
- const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
12257
+ const coinId = coinIdOf(payload);
12258
+ const committedAmount = coinId === void 0 ? "0" : await this.settledAmount(engine, coinId, committed);
12127
12259
  await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
12128
12260
  } catch {
12129
12261
  }
@@ -12166,8 +12298,11 @@ var TransferMachine = class {
12166
12298
  }
12167
12299
  };
12168
12300
  function conflictAmount(engine, payload, op, source) {
12169
- if (op.kind === "split") return BigInt(payload.split.splitAmount);
12170
- return source !== void 0 ? engine.balanceOf(source, payload.coinId) : 0n;
12301
+ const split = splitOf(payload);
12302
+ if (op.kind === "split") return BigInt(split.splitAmount);
12303
+ const coinId = coinIdOf(payload);
12304
+ if (coinId === void 0) return 0n;
12305
+ return source !== void 0 ? engine.balanceOf(source, coinId) : 0n;
12171
12306
  }
12172
12307
 
12173
12308
  // modules/payments-v2/machine/resume.ts
@@ -12274,6 +12409,14 @@ async function runOne(ctx, job, report) {
12274
12409
  report.failed.push(job.transferId);
12275
12410
  return;
12276
12411
  }
12412
+ if (job.payload.kind === "coinless" && !isCoinlessEnvelope(token.valueEnvelope)) {
12413
+ logger.warn(
12414
+ "PaymentsV2",
12415
+ `resume: token intent ${job.transferId} names a source carrying coin value \u2014 refusing`
12416
+ );
12417
+ report.failed.push(job.transferId);
12418
+ return;
12419
+ }
12277
12420
  mine.set(op.sourceTokenId, token);
12278
12421
  }
12279
12422
  try {
@@ -12304,6 +12447,28 @@ async function classifyResumeFailure(deps, stores, transferId, err, report) {
12304
12447
  }
12305
12448
  report.failed.push(transferId);
12306
12449
  }
12450
+ function validateCoinPayload(c) {
12451
+ if (typeof c.coinId !== "string" || typeof c.amount !== "string") {
12452
+ throw new SphereError("intent payload is missing coinId/amount", "VALIDATION_ERROR");
12453
+ }
12454
+ const s = c.split;
12455
+ if (s !== void 0 && (typeof s.tokenId !== "string" || typeof s.splitAmount !== "string" || typeof s.remainderAmount !== "string")) {
12456
+ throw new SphereError("intent payload split spec is malformed", "VALIDATION_ERROR");
12457
+ }
12458
+ return { ...c, kind: "coin" };
12459
+ }
12460
+ function validateCoinlessPayload(p) {
12461
+ if (p.direct?.length !== 1 || typeof p.direct[0] !== "string" || p.direct[0] === "") {
12462
+ throw new SphereError(
12463
+ "token intent payload must name exactly one source token",
12464
+ "VALIDATION_ERROR"
12465
+ );
12466
+ }
12467
+ if (p.split !== void 0) {
12468
+ throw new SphereError("token intent payload cannot carry a split", "VALIDATION_ERROR");
12469
+ }
12470
+ return { ...p, kind: "coinless", direct: [p.direct[0]] };
12471
+ }
12307
12472
  function validatePayload(raw) {
12308
12473
  const p = raw !== null && typeof raw === "object" ? raw : null;
12309
12474
  if (p === null || p.v !== 2 || !Array.isArray(p.direct) || p.direct.some((t) => typeof t !== "string")) {
@@ -12312,14 +12477,17 @@ function validatePayload(raw) {
12312
12477
  "VALIDATION_ERROR"
12313
12478
  );
12314
12479
  }
12315
- if (typeof p.recipient !== "string" || typeof p.coinId !== "string" || typeof p.amount !== "string") {
12316
- throw new SphereError("intent payload is missing recipient/coinId/amount", "VALIDATION_ERROR");
12480
+ if (typeof p.recipient !== "string") {
12481
+ throw new SphereError("intent payload is missing recipient", "VALIDATION_ERROR");
12317
12482
  }
12318
- const s = p.split;
12319
- if (s !== void 0 && (typeof s.tokenId !== "string" || typeof s.splitAmount !== "string" || typeof s.remainderAmount !== "string")) {
12320
- throw new SphereError("intent payload split spec is malformed", "VALIDATION_ERROR");
12483
+ const kind = p.kind ?? "coin";
12484
+ if (kind !== "coin" && kind !== "coinless") {
12485
+ throw new SphereError(
12486
+ `unsupported intent kind '${String(kind)}' \u2014 not resumable by this client`,
12487
+ "VALIDATION_ERROR"
12488
+ );
12321
12489
  }
12322
- return p;
12490
+ return kind === "coinless" ? validateCoinlessPayload(p) : validateCoinPayload(p);
12323
12491
  }
12324
12492
 
12325
12493
  // modules/payments-v2/convergence.ts
@@ -12525,13 +12693,23 @@ async function openRow(entry, journal, decryptPayload) {
12525
12693
  transferId: entry.transferId,
12526
12694
  kind: "open",
12527
12695
  recipient: typeof payload?.recipient === "string" ? payload.recipient : "",
12528
- coinId: typeof payload?.coinId === "string" ? payload.coinId : "",
12529
- amount: typeof payload?.amount === "string" ? payload.amount : "",
12696
+ ...subject(payload),
12530
12697
  legs: { certified, total: Math.max(total, certified) },
12531
12698
  deliveryPending: journal.length > 0,
12532
12699
  createdAt: entry.createdAt
12533
12700
  };
12534
12701
  }
12702
+ function subject(payload) {
12703
+ if (payload?.kind === "coinless") {
12704
+ const tokenId = payload.direct?.[0];
12705
+ return { coinId: "", amount: "", ...typeof tokenId === "string" ? { tokenId } : {} };
12706
+ }
12707
+ const coin = payload;
12708
+ return {
12709
+ coinId: typeof coin?.coinId === "string" ? coin.coinId : "",
12710
+ amount: typeof coin?.amount === "string" ? coin.amount : ""
12711
+ };
12712
+ }
12535
12713
  function journalOnlyRow(transferId, entries) {
12536
12714
  const certified = distinctOpCount(entries);
12537
12715
  return {
@@ -12561,6 +12739,160 @@ function distinctOpCount(entries) {
12561
12739
  return new Set(entries.map((entry) => entry.opIndex)).size;
12562
12740
  }
12563
12741
 
12742
+ // modules/payments-v2/inventory/token-data.ts
12743
+ async function readTokenData(deps, tokenId) {
12744
+ if (deps.view.stateHashOf(tokenId) === void 0) {
12745
+ throw new SphereError(`Token ${tokenId} is not in inventory`, "VALIDATION_ERROR");
12746
+ }
12747
+ const bytes = (await deps.storagePort.getBlobs([tokenId])).get(tokenId);
12748
+ if (bytes === void 0) {
12749
+ throw new SphereError(`Token ${tokenId} has no blob in storage`, "STORAGE_ERROR");
12750
+ }
12751
+ return deps.engine.readTokenData(await deps.engine.decodeToken({ tokenId, token: bytes }));
12752
+ }
12753
+
12754
+ // modules/payments-v2/machine/payload.ts
12755
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
12756
+ return {
12757
+ v: 2,
12758
+ kind: "coin",
12759
+ recipient: recipientPubkey,
12760
+ coinId: request.coinId,
12761
+ amount: request.amount,
12762
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
12763
+ direct: [...spend.plan.direct],
12764
+ ...spend.plan.split !== void 0 ? {
12765
+ split: {
12766
+ tokenId: spend.plan.split.tokenId,
12767
+ splitAmount: spend.plan.split.splitAmount.toString(),
12768
+ remainderAmount: spend.plan.split.remainderAmount.toString()
12769
+ }
12770
+ } : {},
12771
+ spentStates
12772
+ };
12773
+ }
12774
+ function messageOf(err) {
12775
+ return err instanceof Error ? err.message : String(err);
12776
+ }
12777
+ function buildCoinlessPayload(recipientPubkey, request, spentStates) {
12778
+ return {
12779
+ v: 2,
12780
+ kind: "coinless",
12781
+ recipient: recipientPubkey,
12782
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
12783
+ direct: [request.tokenId],
12784
+ spentStates
12785
+ };
12786
+ }
12787
+
12788
+ // modules/payments-v2/mint-params.ts
12789
+ function mintParams(recipientPubkey, coinId, amount) {
12790
+ return { recipientPubkey, value: { assets: [{ coinId, amount }] } };
12791
+ }
12792
+
12793
+ // modules/payments-v2/mint.ts
12794
+ function runMintUnderJournal(deps, input) {
12795
+ return mintUnderJournal(deps, input);
12796
+ }
12797
+ async function mintUnderJournal(deps, input) {
12798
+ const { mintId, coinId, amount } = input;
12799
+ const engine = deps.engine;
12800
+ const entry = {
12801
+ mintId,
12802
+ coinId,
12803
+ amount: amount.toString(),
12804
+ tokenId: "",
12805
+ createdAt: deps.now()
12806
+ };
12807
+ await deps.mintJournal.upsert(entry);
12808
+ let token;
12809
+ try {
12810
+ token = await engine.mint(mintParams(deps.ownPubkeyBytes, coinId, amount), { transferId: mintId });
12811
+ } catch (err) {
12812
+ deps.armHeartbeat();
12813
+ return { success: false, error: messageOf(err) };
12814
+ }
12815
+ if (token.blob.tokenId !== entry.tokenId) {
12816
+ await deps.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
12817
+ }
12818
+ try {
12819
+ await finalizeMint(deps, mintId, token, coinId, amount.toString());
12820
+ } catch (err) {
12821
+ deps.armHeartbeat();
12822
+ return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
12823
+ }
12824
+ return { success: true, tokenId: token.blob.tokenId };
12825
+ }
12826
+ async function finalizeMint(deps, mintId, token, coinId, amount) {
12827
+ const engine = deps.engine;
12828
+ const bytes = token.blob.token;
12829
+ const digest = bytesToHex3(sha256(bytes));
12830
+ const keys = await deps.storagePort.uploadBlobs([{ sha256: digest, bytes }]);
12831
+ const key = keys.get(digest);
12832
+ if (key === void 0) {
12833
+ throw new SphereError(`no upload key returned for mint blob ${digest}`, "STORAGE_ERROR");
12834
+ }
12835
+ await deps.storagePort.applyDelta({
12836
+ transferId: mintId,
12837
+ spent: [],
12838
+ added: [{ tokenId: token.blob.tokenId, key }]
12839
+ });
12840
+ await deps.recordMint({
12841
+ tokenId: token.blob.tokenId,
12842
+ assets: [{ coinId, amount }]
12843
+ });
12844
+ await deps.mintJournal.removeByKey(mintId);
12845
+ deps.noteHeldState(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
12846
+ deps.refreshView();
12847
+ }
12848
+
12849
+ // modules/payments-v2/send-coinless.ts
12850
+ async function materializeCoinlessSpend(deps, input) {
12851
+ const { engine } = deps;
12852
+ const tokenId = input.request.tokenId;
12853
+ const bytes = (await deps.storagePort.getBlobs([tokenId])).get(tokenId);
12854
+ if (bytes === void 0) {
12855
+ throw new SphereError(`Selected source ${tokenId} has no blob in storage`, "STORAGE_ERROR");
12856
+ }
12857
+ const token = await engine.decodeToken({ tokenId, token: bytes });
12858
+ if (!isCoinlessEnvelope(token.valueEnvelope)) {
12859
+ throw new SphereError(
12860
+ `Token ${tokenId} carries coin value and cannot be sent with sendCoinless \u2014 use send()`,
12861
+ "VALIDATION_ERROR"
12862
+ );
12863
+ }
12864
+ const keys = await engine.deliveryKeys(bytes);
12865
+ const payload = buildCoinlessPayload(input.recipientPubkey, input.request, {
12866
+ [tokenId]: { local: keys.stateHash, protocol: keys.stateHash }
12867
+ });
12868
+ const plan = {
12869
+ transferId: input.transferId,
12870
+ recipientPubkey: input.recipientPubkey,
12871
+ payload,
12872
+ ops: buildOps(payload),
12873
+ sources: /* @__PURE__ */ new Map([[tokenId, token]]),
12874
+ ...input.request.memo !== void 0 ? { memo: input.request.memo } : {}
12875
+ };
12876
+ return { transferId: input.transferId, coinId: "", plan, sourceIds: input.sourceIds, sourceTokens: [] };
12877
+ }
12878
+
12879
+ // modules/payments-v2/send-errors.ts
12880
+ function partialize(err, run) {
12881
+ if (run.delivered.length === 0) return err;
12882
+ return new PartialSendConflictError(
12883
+ "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.",
12884
+ run.firstPartialId ?? "",
12885
+ [...run.delivered],
12886
+ run.amount,
12887
+ err
12888
+ );
12889
+ }
12890
+ function stampTransferId(err, transferId) {
12891
+ if (err instanceof SphereError && isPossiblyCommittedSendOutcome(err) && err.transferId === void 0) {
12892
+ err.transferId = transferId;
12893
+ }
12894
+ }
12895
+
12564
12896
  // modules/payments-v2/recipient.ts
12565
12897
  var ATTENTION_RECIPIENT_NETWORK_UNVERIFIED = "recipient:network-unverified";
12566
12898
  async function requireSameNetworkRecipient(deps, identifier) {
@@ -12609,11 +12941,6 @@ async function splitOpIndex(deps, envelope) {
12609
12941
  return Array.isArray(payload.direct) ? payload.direct.length : 0;
12610
12942
  }
12611
12943
 
12612
- // modules/payments-v2/mint-params.ts
12613
- function mintParams(recipientPubkey, coinId, amount) {
12614
- return { recipientPubkey, value: { assets: [{ coinId, amount }] } };
12615
- }
12616
-
12617
12944
  // modules/payments-v2/prewarm-cache.ts
12618
12945
  var PrewarmCache = class _PrewarmCache {
12619
12946
  entries = /* @__PURE__ */ new Map();
@@ -12787,30 +13114,15 @@ function transferringToken(tokenId, coinId, amount, registry, now) {
12787
13114
  });
12788
13115
  }
12789
13116
 
12790
- // modules/payments-v2/machine/payload.ts
12791
- function buildPayload(recipientPubkey, request, spend, spentStates) {
12792
- return {
12793
- v: 2,
12794
- recipient: recipientPubkey,
12795
- coinId: request.coinId,
12796
- amount: request.amount,
12797
- ...request.memo !== void 0 ? { memo: request.memo } : {},
12798
- direct: [...spend.plan.direct],
12799
- ...spend.plan.split !== void 0 ? {
12800
- split: {
12801
- tokenId: spend.plan.split.tokenId,
12802
- splitAmount: spend.plan.split.splitAmount.toString(),
12803
- remainderAmount: spend.plan.split.remainderAmount.toString()
12804
- }
12805
- } : {},
12806
- spentStates
12807
- };
13117
+ // modules/payments-v2/history/History.ts
13118
+ function wireAssets(assets) {
13119
+ return assets.map((a) => ({ coinId: a.coinId, amount: a.amount }));
12808
13120
  }
12809
- function messageOf(err) {
12810
- return err instanceof Error ? err.message : String(err);
13121
+ function isPermanentReject(err) {
13122
+ const status = err?.status;
13123
+ if (typeof status !== "number") return false;
13124
+ return status >= 400 && status < 500 && status !== 408 && status !== 429;
12811
13125
  }
12812
-
12813
- // modules/payments-v2/history/History.ts
12814
13126
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
12815
13127
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
12816
13128
  var History = class {
@@ -12835,7 +13147,7 @@ var History = class {
12835
13147
  dedupKey: input.transferId,
12836
13148
  id: (this.deps.newId ?? randomUUID)(),
12837
13149
  type: "SENT",
12838
- assets: [{ coinId: input.coinId, amount: input.amount }],
13150
+ assets: wireAssets(input.assets),
12839
13151
  ts: this.ts(input.timestamp),
12840
13152
  transferId: input.transferId,
12841
13153
  ...this.wireTokenId(input.tokenId),
@@ -12848,7 +13160,7 @@ var History = class {
12848
13160
  dedupKey: `RECEIVED:${input.tokenId.toLowerCase()}:${input.stateHash.toLowerCase()}`,
12849
13161
  id: (this.deps.newId ?? randomUUID)(),
12850
13162
  type: "RECEIVED",
12851
- assets: [{ coinId: input.coinId, amount: input.amount }],
13163
+ assets: wireAssets(input.assets),
12852
13164
  ts: this.ts(input.timestamp),
12853
13165
  ...input.transferId !== void 0 ? { transferId: input.transferId } : {},
12854
13166
  ...this.wireTokenId(input.tokenId),
@@ -12861,7 +13173,7 @@ var History = class {
12861
13173
  dedupKey: `MINT:${input.tokenId.toLowerCase()}`,
12862
13174
  id: (this.deps.newId ?? randomUUID)(),
12863
13175
  type: "MINT",
12864
- assets: [{ coinId: input.coinId, amount: input.amount }],
13176
+ assets: wireAssets(input.assets),
12865
13177
  ts: this.ts(input.timestamp),
12866
13178
  ...this.wireTokenId(input.tokenId)
12867
13179
  }));
@@ -12872,7 +13184,10 @@ var History = class {
12872
13184
  try {
12873
13185
  await this.deps.client.postHistory([record]);
12874
13186
  } catch (err) {
12875
- this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
13187
+ this.deps.log?.(
13188
+ isPermanentReject(err) ? "history POST REJECTED \u2014 the record shape is refused; no retry can fix it" : "history POST failed (dedupKey makes retry safe)",
13189
+ err
13190
+ );
12876
13191
  return;
12877
13192
  }
12878
13193
  this.deps.emit("history:updated", this.toEntry(record));
@@ -12939,6 +13254,13 @@ var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
12939
13254
  function stateKey(tokenId, stateHash) {
12940
13255
  return `${tokenId}:${stateHash}`;
12941
13256
  }
13257
+ function unchanged(prev, item, tokenType) {
13258
+ return prev.seq === item.seq && prev.status === item.status && prev.stateHash === item.stateHash && tokenType === prev.tokenType;
13259
+ }
13260
+ function isCoinless(item, prev) {
13261
+ if (item.status !== "active") return prev?.coinless ?? false;
13262
+ return (item.assets?.length ?? 0) === 0;
13263
+ }
12942
13264
  var InventoryView = class {
12943
13265
  constructor(deps) {
12944
13266
  this.deps = deps;
@@ -13032,14 +13354,26 @@ var InventoryView = class {
13032
13354
  pool(coinId) {
13033
13355
  const out = [];
13034
13356
  for (const [tokenId, entry] of this.mirror) {
13035
- if (entry.status !== "active") continue;
13036
- if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) continue;
13037
- if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
13357
+ if (!this.isSpendable(tokenId, entry)) continue;
13038
13358
  const asset = entry.assets.find((a) => a.coinId === coinId);
13039
13359
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
13040
13360
  }
13041
13361
  return out;
13042
13362
  }
13363
+ /**
13364
+ * The eligibility gates every spend shares, coin or token — ONE definition, so a
13365
+ * single probe covers both verbs and neither can drift from the other.
13366
+ */
13367
+ isSpendable(tokenId, entry) {
13368
+ if (entry.status !== "active") return false;
13369
+ if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) return false;
13370
+ return !this.suspected.has(stateKey(tokenId, entry.stateHash));
13371
+ }
13372
+ /** #777: is this NAMED token a spendable COINLESS holding? Never a coin source. */
13373
+ spendableCoinless(tokenId) {
13374
+ const entry = this.mirror.get(tokenId);
13375
+ return entry !== void 0 && entry.coinless && this.isSpendable(tokenId, entry);
13376
+ }
13043
13377
  /**
13044
13378
  * Current mirrored state of an ACTIVE token, or undefined when it is unknown
13045
13379
  * or tombstoned. Exists so a cached blob can be state-scoped (F6): a blob is
@@ -13065,6 +13399,29 @@ var InventoryView = class {
13065
13399
  }
13066
13400
  return out;
13067
13401
  }
13402
+ /**
13403
+ * Coinless holdings (#777) — DISJOINT from tokens(): an entry is coinless
13404
+ * exactly when it is not, so no mirror row can appear in both reads.
13405
+ */
13406
+ coinless(registry) {
13407
+ const out = [];
13408
+ for (const [tokenId, entry] of this.mirror) {
13409
+ if (entry.status !== "active" || !entry.coinless) continue;
13410
+ const meta = entry.tokenType !== void 0 ? registry.getTypeMeta?.(entry.tokenType) : null;
13411
+ out.push({
13412
+ tokenId,
13413
+ ...entry.tokenType !== void 0 ? { tokenType: entry.tokenType } : {},
13414
+ ...meta ? { name: meta.name } : {},
13415
+ ...meta?.iconUrl != null ? { iconUrl: meta.iconUrl } : {},
13416
+ stateHash: entry.stateHash,
13417
+ transferring: this.held(tokenId),
13418
+ ...this.suspected.has(stateKey(tokenId, entry.stateHash)) ? { suspectedSpent: true } : {},
13419
+ createdAt: entry.createdAt,
13420
+ updatedAt: entry.updatedAt
13421
+ });
13422
+ }
13423
+ return out;
13424
+ }
13068
13425
  async assets(registry, price) {
13069
13426
  const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
13070
13427
  if (!price || raw.length === 0) return raw;
@@ -13136,14 +13493,19 @@ var InventoryView = class {
13136
13493
  applyOne(item, now) {
13137
13494
  const prev = this.mirror.get(item.tokenId);
13138
13495
  if (prev && item.seq < prev.seq) return false;
13139
- if (prev && prev.seq === item.seq && prev.status === item.status && prev.stateHash === item.stateHash) {
13140
- return false;
13141
- }
13496
+ const tokenType = item.tokenType ?? prev?.tokenType;
13497
+ if (prev && unchanged(prev, item, tokenType)) return false;
13142
13498
  this.mirror.set(item.tokenId, {
13143
13499
  stateHash: item.stateHash,
13144
13500
  seq: item.seq,
13145
13501
  status: item.status,
13146
- assets: item.assets ?? prev?.assets ?? [],
13502
+ // ONLY a tombstone inherits: it omits assets for an unrelated reason and
13503
+ // recoverRemoved must know the amount it restores. An ACTIVE row that omits
13504
+ // them is stating coinlessness, and inheriting there would keep stale assets
13505
+ // in tokens() while `coinless` is true — the row in BOTH reads (§16).
13506
+ assets: item.assets ?? (item.status === "removed" ? prev?.assets ?? [] : []),
13507
+ coinless: isCoinless(item, prev),
13508
+ ...tokenType !== void 0 ? { tokenType } : {},
13147
13509
  createdAt: prev?.createdAt ?? now,
13148
13510
  updatedAt: now
13149
13511
  });
@@ -13379,7 +13741,12 @@ async function screen(deps, engine, entry) {
13379
13741
  }
13380
13742
  return {
13381
13743
  kind: "accept",
13382
- record: { tokenId: keys.tokenId, stateHash: keys.stateHash, assets: toAssetAmounts(token) }
13744
+ record: {
13745
+ tokenId: keys.tokenId,
13746
+ stateHash: keys.stateHash,
13747
+ assets: toAssetAmounts(token),
13748
+ ...isCoinlessEnvelope2(token.valueEnvelope) ? { tokenType: token.tokenType } : {}
13749
+ }
13383
13750
  };
13384
13751
  }
13385
13752
  async function announce(deps, entry, record) {
@@ -13390,6 +13757,7 @@ async function announce(deps, entry, record) {
13390
13757
  tokenId: record.tokenId,
13391
13758
  stateHash: record.stateHash,
13392
13759
  assets: record.assets,
13760
+ ...record.tokenType !== void 0 ? { tokenType: record.tokenType } : {},
13393
13761
  ...entry.senderPubkey !== void 0 ? { senderPubkey: entry.senderPubkey } : {},
13394
13762
  ...entry.senderNametag !== void 0 ? { senderNametag: entry.senderNametag } : {},
13395
13763
  ...entry.memo !== void 0 ? { memo: entry.memo } : {},
@@ -13403,6 +13771,19 @@ async function announce(deps, entry, record) {
13403
13771
  senderPubkey: entry.senderPubkey ?? "",
13404
13772
  ...entry.senderNametag !== void 0 ? { senderNametag: entry.senderNametag } : {},
13405
13773
  tokens: record.assets.map((asset) => toUiToken(record.tokenId, asset, deps.registry, receivedAt)),
13774
+ // #777: named here rather than mapped from assets, which announced an EMPTY list.
13775
+ ...record.tokenType !== void 0 ? {
13776
+ coinless: [
13777
+ {
13778
+ tokenId: record.tokenId,
13779
+ tokenType: record.tokenType,
13780
+ stateHash: record.stateHash,
13781
+ transferring: false,
13782
+ createdAt: receivedAt,
13783
+ updatedAt: receivedAt
13784
+ }
13785
+ ]
13786
+ } : {},
13406
13787
  ...entry.memo !== void 0 ? { memo: entry.memo } : {},
13407
13788
  receivedAt
13408
13789
  };
@@ -13501,6 +13882,9 @@ function claimAck(entry) {
13501
13882
  function rejectAck(entry, reason) {
13502
13883
  return { deliveryId: entry.deliveryId, disposition: "rejected", reason, cursor: entry.cursor };
13503
13884
  }
13885
+ function isCoinlessEnvelope2(envelope) {
13886
+ return envelope.startsWith("none_");
13887
+ }
13504
13888
  function toAssetAmounts(token) {
13505
13889
  return (token.value?.assets ?? []).map((asset) => ({
13506
13890
  coinId: asset.coinId,
@@ -14068,6 +14452,37 @@ var SpendQueue = class {
14068
14452
  }
14069
14453
  return { kind: "queued", settled: this.enqueue(reservationId, request.coinId, amount) };
14070
14454
  }
14455
+ /**
14456
+ * #777: reserve a NAMED coinless source. Never queues — there is nothing to wait
14457
+ * for. A coin plan waits because some OTHER combination may free up; a named
14458
+ * token is either free now or held by a reservation that only its own transfer
14459
+ * can release, so queueing would block until a timeout on a spend that cannot
14460
+ * become possible.
14461
+ */
14462
+ planCoinless(reservationId, tokenId) {
14463
+ if (this.destroyed) {
14464
+ throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
14465
+ }
14466
+ const unproven = this.deps.ledger.unprovenReason();
14467
+ if (unproven !== null) {
14468
+ throw new SphereError(`Cannot spend yet: ${unproven}`, "SEND_SYNC_PENDING");
14469
+ }
14470
+ if (this.deps.spendableCoinless?.(tokenId) !== true) {
14471
+ throw new SphereError(
14472
+ `Token ${tokenId} is not a spendable coinless holding`,
14473
+ "VALIDATION_ERROR"
14474
+ );
14475
+ }
14476
+ const holder = this.deps.ledger.holderOf(tokenId);
14477
+ if (holder !== void 0) {
14478
+ throw new SphereError(
14479
+ `Token ${tokenId} is already reserved by transfer ${holder}`,
14480
+ "VALIDATION_ERROR"
14481
+ );
14482
+ }
14483
+ this.deps.ledger.reserve(reservationId, [{ tokenId }]);
14484
+ return { reservationId, plan: { direct: [tokenId] } };
14485
+ }
14071
14486
  /**
14072
14487
  * The sources `plan()` WOULD pick right now, reserving nothing and queueing
14073
14488
  * nothing — a read-only probe used to warm blobs while the user is still
@@ -14257,6 +14672,7 @@ function composeFacadeParts(deps, hooks) {
14257
14672
  const queue = new SpendQueue({
14258
14673
  ledger,
14259
14674
  getPool: (coinId) => view.pool(coinId),
14675
+ spendableCoinless: (tokenId) => view.spendableCoinless(tokenId),
14260
14676
  ...deps.workBudget !== void 0 ? { workBudget: deps.workBudget } : {}
14261
14677
  });
14262
14678
  const historyStore = new History({
@@ -14340,10 +14756,9 @@ function buildMachineDeps(deps, hooks, historyStore) {
14340
14756
  recordHistory: async ({ transferId, payload, committedAmount }) => {
14341
14757
  await historyStore.recordSent({
14342
14758
  transferId,
14343
- coinId: payload.coinId,
14344
- // §5.9: the SETTLED amount (machine-computed from the certified
14345
- // recipient blobs), never payload.amount — the plan.
14346
- amount: committedAmount,
14759
+ // §5.9: the SETTLED amount, never payload.amount — the plan. A token
14760
+ // spend moved no coin: `assets: []` + tokenId (wallet-api#151 / §10).
14761
+ ...payload.kind === "coinless" ? { assets: [], tokenId: payload.direct[0] } : { assets: [{ coinId: payload.coinId, amount: committedAmount }] },
14347
14762
  recipientPubkey: payload.recipient,
14348
14763
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
14349
14764
  });
@@ -14378,12 +14793,10 @@ function buildReceive(deps, hooks, historyStore, heldStates, refreshView) {
14378
14793
  });
14379
14794
  }
14380
14795
  async function recordReceived(historyStore, record) {
14381
- const first = record.assets[0];
14382
14796
  await historyStore.recordReceived({
14383
14797
  tokenId: record.tokenId,
14384
14798
  stateHash: record.stateHash,
14385
- coinId: first?.coinId ?? "",
14386
- amount: first?.amount ?? "0",
14799
+ assets: record.assets,
14387
14800
  ...record.senderPubkey !== void 0 ? { senderPubkey: record.senderPubkey } : {},
14388
14801
  ...record.senderNametag !== void 0 ? { senderNametag: record.senderNametag } : {},
14389
14802
  ...record.memo !== void 0 ? { memo: record.memo } : {},
@@ -14531,6 +14944,13 @@ var PaymentsFacade = class {
14531
14944
  tokens(filter) {
14532
14945
  return this.view.tokens(this.deps.registry, filter);
14533
14946
  }
14947
+ coinless() {
14948
+ return this.view.coinless(this.deps.registry);
14949
+ }
14950
+ tokenData(tokenId) {
14951
+ const deps = { engine: this.engine(), view: this.view, storagePort: this.deps.storagePort };
14952
+ return readTokenData(deps, tokenId);
14953
+ }
14534
14954
  history(page) {
14535
14955
  return this.historyStore.page(page ?? {});
14536
14956
  }
@@ -14555,6 +14975,9 @@ var PaymentsFacade = class {
14555
14975
  send(request) {
14556
14976
  return this.track(this.sendOutcome(request));
14557
14977
  }
14978
+ sendCoinless(request) {
14979
+ return this.track(this.sendCoinlessOutcome(request));
14980
+ }
14558
14981
  async receive() {
14559
14982
  const transfers = await this.track(this.receiveLoop.drainOnce());
14560
14983
  return { transfers };
@@ -14585,12 +15008,32 @@ var PaymentsFacade = class {
14585
15008
  nowMs() {
14586
15009
  return (this.deps.now ?? Date.now)();
14587
15010
  }
15011
+ mintDeps() {
15012
+ return {
15013
+ engine: this.engine(),
15014
+ mintJournal: this.machineStores.mintJournal,
15015
+ storagePort: this.deps.storagePort,
15016
+ recordMint: (input) => this.historyStore.recordMint(input),
15017
+ armHeartbeat: () => this.heartbeat.arm(),
15018
+ noteHeldState: (tokenId, stateHash) => void this.heldStates.set(tokenId, stateHash),
15019
+ refreshView: () => this.trackTail(this.view.delta()),
15020
+ ownPubkeyBytes: this.ownPubkeyBytes,
15021
+ now: () => this.nowMs()
15022
+ };
15023
+ }
14588
15024
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
14589
15025
  /** The ONE place a send() outcome is shaped: success emits in finishSend, a
14590
15026
  * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
14591
- async sendOutcome(request) {
15027
+ sendOutcome(request) {
15028
+ return this.runJob({ kind: "coin", request }, request.amount);
15029
+ }
15030
+ /** A token spend has no amount; '0' keeps the shortfall arithmetic total-free. */
15031
+ sendCoinlessOutcome(request) {
15032
+ return this.runJob({ kind: "coinless", request }, "0");
15033
+ }
15034
+ async runJob(job, amount) {
14592
15035
  const run = {
14593
- amount: request.amount,
15036
+ amount,
14594
15037
  delivered: [],
14595
15038
  sentTokens: [],
14596
15039
  tokenTransfers: [],
@@ -14600,7 +15043,7 @@ var PaymentsFacade = class {
14600
15043
  lastTransferId: ""
14601
15044
  };
14602
15045
  try {
14603
- return await this.sendWithPolicy(request, run);
15046
+ return await this.sendWithPolicy(job, run);
14604
15047
  } catch (err) {
14605
15048
  this.emitCleanFailure(err, run.lastTransferId);
14606
15049
  throw err;
@@ -14620,19 +15063,19 @@ var PaymentsFacade = class {
14620
15063
  };
14621
15064
  this.deps.emit("transfer:updated", failed);
14622
15065
  }
14623
- async sendWithPolicy(request, run) {
14624
- const recipient = await requireSameNetworkRecipient(this.deps, request.recipient);
15066
+ async sendWithPolicy(job, run) {
15067
+ const recipient = await requireSameNetworkRecipient(this.deps, job.request.recipient);
14625
15068
  for (let attempt = 0; ; attempt++) {
14626
15069
  let ctx;
14627
15070
  try {
14628
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
15071
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, job, run);
14629
15072
  } catch (err) {
14630
- throw this.partialize(err, run);
15073
+ throw partialize(err, run);
14631
15074
  }
14632
15075
  const disposition = await this.runAttempt(ctx);
14633
- if (disposition.kind === "rethrow") throw this.partialize(disposition.error, run);
15076
+ if (disposition.kind === "rethrow") throw partialize(disposition.error, run);
14634
15077
  if (disposition.kind === "retry-full") {
14635
- if (attempt >= MAX_RESELECT) throw this.partialize(disposition.error, run);
15078
+ if (job.kind === "coinless" || attempt >= MAX_RESELECT) throw partialize(disposition.error, run);
14636
15079
  continue;
14637
15080
  }
14638
15081
  if (disposition.kind === "success") {
@@ -14688,7 +15131,7 @@ var PaymentsFacade = class {
14688
15131
  async disposeFailedAttempt(ctx, err) {
14689
15132
  if (isPossiblyCommittedSendOutcome(err)) {
14690
15133
  this.settleKeepOpen(ctx);
14691
- this.stampTransferId(err, ctx.transferId);
15134
+ stampTransferId(err, ctx.transferId);
14692
15135
  return { kind: "rethrow", error: err };
14693
15136
  }
14694
15137
  const backstop = await this.machineStores.backstop.getByKey(ctx.transferId);
@@ -14722,7 +15165,7 @@ var PaymentsFacade = class {
14722
15165
  async disposeConvergeFailure(ctx, err) {
14723
15166
  if (isPossiblyCommittedSendOutcome(err)) {
14724
15167
  this.settleKeepOpen(ctx);
14725
- this.stampTransferId(err, ctx.transferId);
15168
+ stampTransferId(err, ctx.transferId);
14726
15169
  return { kind: "rethrow", error: err };
14727
15170
  }
14728
15171
  if (classifyError(err) === "conflict") {
@@ -14733,9 +15176,23 @@ var PaymentsFacade = class {
14733
15176
  }
14734
15177
  return { kind: "rethrow", error: err };
14735
15178
  }
14736
- async planAndMaterialize(recipientPubkey, request, run) {
15179
+ async planAndMaterialize(recipientPubkey, job, run) {
14737
15180
  const transferId = this.newId();
14738
15181
  run.lastTransferId = transferId;
15182
+ if (job.kind === "coinless") {
15183
+ const spend2 = this.queue.planCoinless(transferId, job.request.tokenId);
15184
+ const sourceIds2 = this.markPlanned(transferId, "", spend2);
15185
+ try {
15186
+ return await materializeCoinlessSpend(
15187
+ { engine: this.engine(), storagePort: this.deps.storagePort },
15188
+ { transferId, recipientPubkey, request: job.request, sourceIds: sourceIds2 }
15189
+ );
15190
+ } catch (err) {
15191
+ this.settleFailure(transferId, "", sourceIds2);
15192
+ throw err;
15193
+ }
15194
+ }
15195
+ const request = { ...job.request, amount: run.amount };
14739
15196
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
14740
15197
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
14741
15198
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -14869,22 +15326,6 @@ var PaymentsFacade = class {
14869
15326
  return result;
14870
15327
  }
14871
15328
  /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
14872
- partialize(err, run) {
14873
- if (run.delivered.length === 0) return err;
14874
- return new PartialSendConflictError(
14875
- "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.",
14876
- run.firstPartialId ?? "",
14877
- run.delivered,
14878
- run.amount,
14879
- err
14880
- );
14881
- }
14882
- /** #441: possibly-committed errors must carry the transferId for the settling journal. */
14883
- stampTransferId(err, transferId) {
14884
- if (err instanceof SphereError && isPossiblyCommittedSendOutcome(err) && err.transferId === void 0) {
14885
- err.transferId = transferId;
14886
- }
14887
- }
14888
15329
  async softAbort(transferId) {
14889
15330
  try {
14890
15331
  await this.deps.client.abortIntent(transferId);
@@ -14900,57 +15341,11 @@ var PaymentsFacade = class {
14900
15341
  const mintId = this.newId();
14901
15342
  this.activeMoneyOps.add(mintId);
14902
15343
  try {
14903
- return await this.mintUnderJournal(mintId, coinId, amount);
15344
+ return await runMintUnderJournal(this.mintDeps(), { mintId, coinId, amount });
14904
15345
  } finally {
14905
15346
  this.activeMoneyOps.delete(mintId);
14906
15347
  }
14907
15348
  }
14908
- async mintUnderJournal(mintId, coinId, amount) {
14909
- const engine = this.engine();
14910
- const entry = {
14911
- mintId,
14912
- coinId,
14913
- amount: amount.toString(),
14914
- tokenId: "",
14915
- createdAt: this.nowMs()
14916
- };
14917
- await this.machineStores.mintJournal.upsert(entry);
14918
- let token;
14919
- try {
14920
- token = await engine.mint(mintParams(this.ownPubkeyBytes, coinId, amount), { transferId: mintId });
14921
- } catch (err) {
14922
- this.heartbeat.arm();
14923
- return { success: false, error: messageOf(err) };
14924
- }
14925
- if (token.blob.tokenId !== entry.tokenId) {
14926
- await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
14927
- }
14928
- try {
14929
- await this.finalizeMint(engine, mintId, token, coinId, amount.toString());
14930
- } catch (err) {
14931
- this.heartbeat.arm();
14932
- return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
14933
- }
14934
- return { success: true, tokenId: token.blob.tokenId };
14935
- }
14936
- async finalizeMint(engine, mintId, token, coinId, amount) {
14937
- const bytes = token.blob.token;
14938
- const digest = bytesToHex3(sha256(bytes));
14939
- const keys = await this.deps.storagePort.uploadBlobs([{ sha256: digest, bytes }]);
14940
- const key = keys.get(digest);
14941
- if (key === void 0) {
14942
- throw new SphereError(`no upload key returned for mint blob ${digest}`, "STORAGE_ERROR");
14943
- }
14944
- await this.deps.storagePort.applyDelta({
14945
- transferId: mintId,
14946
- spent: [],
14947
- added: [{ tokenId: token.blob.tokenId, key }]
14948
- });
14949
- await this.historyStore.recordMint({ tokenId: token.blob.tokenId, coinId, amount });
14950
- await this.machineStores.mintJournal.removeByKey(mintId);
14951
- this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
14952
- this.trackTail(this.view.delta());
14953
- }
14954
15349
  /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */
14955
15350
  async replayMints() {
14956
15351
  let resolved = 0;
@@ -14982,7 +15377,7 @@ var PaymentsFacade = class {
14982
15377
  if (token.blob.tokenId !== entry.tokenId) {
14983
15378
  await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
14984
15379
  }
14985
- await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount);
15380
+ await finalizeMint(this.mintDeps(), entry.mintId, token, entry.coinId, entry.amount);
14986
15381
  return true;
14987
15382
  }
14988
15383
  async tokenInServerInventory(tokenId) {