@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
package/dist/index.js CHANGED
@@ -8146,6 +8146,8 @@ var FETCH_TIMEOUT_MS = 1e4;
8146
8146
  var TokenRegistry = class _TokenRegistry {
8147
8147
  static instance = null;
8148
8148
  definitionsById;
8149
+ /** Non-fungible definitions keyed by TOKEN TYPE — a separate namespace (#147). */
8150
+ definitionsByType;
8149
8151
  definitionsBySymbol;
8150
8152
  definitionsByName;
8151
8153
  // Remote refresh state
@@ -8164,6 +8166,7 @@ var TokenRegistry = class _TokenRegistry {
8164
8166
  inFlight = null;
8165
8167
  constructor() {
8166
8168
  this.definitionsById = /* @__PURE__ */ new Map();
8169
+ this.definitionsByType = /* @__PURE__ */ new Map();
8167
8170
  this.definitionsBySymbol = /* @__PURE__ */ new Map();
8168
8171
  this.definitionsByName = /* @__PURE__ */ new Map();
8169
8172
  }
@@ -8391,9 +8394,11 @@ var TokenRegistry = class _TokenRegistry {
8391
8394
  this.definitionsById.clear();
8392
8395
  this.definitionsBySymbol.clear();
8393
8396
  this.definitionsByName.clear();
8397
+ this.definitionsByType.clear();
8394
8398
  for (const def of definitions) {
8395
8399
  const idLower = def.id.toLowerCase();
8396
8400
  this.definitionsById.set(idLower, def);
8401
+ if (def.assetKind === "non-fungible") this.definitionsByType.set(idLower, def);
8397
8402
  if (def.symbol) {
8398
8403
  this.definitionsBySymbol.set(def.symbol.toUpperCase(), def);
8399
8404
  }
@@ -8607,6 +8612,20 @@ var TokenRegistry = class _TokenRegistry {
8607
8612
  getAllDefinitions() {
8608
8613
  return Array.from(this.definitionsById.values());
8609
8614
  }
8615
+ /**
8616
+ * Definition for a coinless token's genesis TOKEN TYPE (#777). Never falls back
8617
+ * to `getDefinition`: a type and a coin id live in different namespaces, so a
8618
+ * type that collided with a coin id would otherwise render as that coin.
8619
+ */
8620
+ getTypeMeta(tokenType) {
8621
+ const def = this.getTypeDefinition(tokenType);
8622
+ if (!def) return null;
8623
+ return { name: def.name, iconUrl: def.icons?.[0]?.url ?? null };
8624
+ }
8625
+ getTypeDefinition(tokenType) {
8626
+ if (!tokenType) return void 0;
8627
+ return this.definitionsByType.get(tokenType.toLowerCase());
8628
+ }
8610
8629
  /**
8611
8630
  * Get all fungible token definitions
8612
8631
  * @returns Array of fungible token definitions
@@ -9441,6 +9460,8 @@ import { DataHasherFactory } from "@unicitylabs/state-transition-sdk/lib/crypto/
9441
9460
  import { CborSerializer } from "@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborSerializer.js";
9442
9461
  import { CborDeserializer } from "@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborDeserializer.js";
9443
9462
  import { CborError } from "@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborError.js";
9463
+ import { CborReader } from "@unicitylabs/state-transition-sdk/lib/serialization/cbor/CborReader.js";
9464
+ import { MajorType } from "@unicitylabs/state-transition-sdk/lib/serialization/cbor/MajorType.js";
9444
9465
  import { Asset } from "@unicitylabs/state-transition-sdk/lib/payment/asset/Asset.js";
9445
9466
  import { AssetId } from "@unicitylabs/state-transition-sdk/lib/payment/asset/AssetId.js";
9446
9467
  import { PaymentAssetCollection } from "@unicitylabs/state-transition-sdk/lib/payment/asset/PaymentAssetCollection.js";
@@ -9853,6 +9874,126 @@ async function burntTokenFromCheckpoint(deps, storedBytes, reDerivedBurnTx, sour
9853
9874
  return burntToken;
9854
9875
  }
9855
9876
 
9877
+ // token-engine/value-envelope.ts
9878
+ function isCoinlessEnvelope(envelope) {
9879
+ return envelope.startsWith("none_");
9880
+ }
9881
+ var SELF_DESCRIBED_CBOR_TAG = 55799n;
9882
+ var CBOR_MAJOR_TYPE_MASK = 224;
9883
+ var describe = (error) => error instanceof Error ? error.message : String(error);
9884
+ var invalid = (message) => new SphereError(message, "VALIDATION_ERROR");
9885
+ function majorTypeOf(bytes) {
9886
+ const first = bytes.at(0);
9887
+ return first === void 0 ? null : first & CBOR_MAJOR_TYPE_MASK;
9888
+ }
9889
+ function headTagNumber(bytes) {
9890
+ try {
9891
+ return new CborReader(bytes).readLength(MajorType.TAG);
9892
+ } catch {
9893
+ return null;
9894
+ }
9895
+ }
9896
+ function isAssetShaped(item) {
9897
+ try {
9898
+ return CborDeserializer.decodeArray(item, 2).every(
9899
+ (field) => majorTypeOf(field) === MajorType.BYTE_STRING
9900
+ );
9901
+ } catch {
9902
+ return false;
9903
+ }
9904
+ }
9905
+ function arrayItemsOrNull(bytes) {
9906
+ try {
9907
+ return CborDeserializer.decodeArray(bytes);
9908
+ } catch {
9909
+ return null;
9910
+ }
9911
+ }
9912
+ function decodeSphere(genesisData) {
9913
+ try {
9914
+ return SpherePaymentData.fromCBOR(genesisData).toValue();
9915
+ } catch (error) {
9916
+ throw invalid(`Failed to decode token payment data: ${describe(error)}`);
9917
+ }
9918
+ }
9919
+ function taggedItemBody(genesisData) {
9920
+ try {
9921
+ return CborDeserializer.decodeTag(genesisData).data;
9922
+ } catch (error) {
9923
+ throw invalid(
9924
+ `Token value payload is a CBOR tag whose item is malformed or carries trailing bytes: ${describe(error)}`
9925
+ );
9926
+ }
9927
+ }
9928
+ function selfDescribedBodyCarriesValue(body) {
9929
+ const major = majorTypeOf(body);
9930
+ if (major === MajorType.TAG) {
9931
+ const inner = headTagNumber(body);
9932
+ return inner === SpherePaymentData.CBOR_TAG || inner === SELF_DESCRIBED_CBOR_TAG;
9933
+ }
9934
+ if (major === MajorType.ARRAY) {
9935
+ const items = arrayItemsOrNull(body);
9936
+ return items !== null && items.some(isAssetShaped);
9937
+ }
9938
+ return false;
9939
+ }
9940
+ function classifyTagged(genesisData) {
9941
+ const tag = headTagNumber(genesisData);
9942
+ if (tag === null) {
9943
+ throw invalid(
9944
+ "Token value payload has an unreadable or non-canonically encoded CBOR tag head \u2014 it could be a malformed SpherePaymentData envelope"
9945
+ );
9946
+ }
9947
+ if (tag === SpherePaymentData.CBOR_TAG) {
9948
+ return { envelope: "sphere", value: decodeSphere(genesisData) };
9949
+ }
9950
+ const body = taggedItemBody(genesisData);
9951
+ if (tag === SELF_DESCRIBED_CBOR_TAG && selfDescribedBodyCarriesValue(body)) {
9952
+ throw invalid(
9953
+ "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"
9954
+ );
9955
+ }
9956
+ return { envelope: "none_tag", value: null };
9957
+ }
9958
+ function classifyArray(genesisData) {
9959
+ const items = arrayItemsOrNull(genesisData);
9960
+ if (items === null) {
9961
+ throw invalid("Token value payload is not PaymentAssetCollection: malformed CBOR array");
9962
+ }
9963
+ return items.some(isAssetShaped) ? { envelope: "bare_collection", value: null } : { envelope: "none_other", value: null };
9964
+ }
9965
+ function classifyValueEnvelope(genesisData) {
9966
+ if (genesisData === null) return { envelope: "none_absent", value: null };
9967
+ const major = majorTypeOf(genesisData);
9968
+ if (major === null) return { envelope: "none_absent", value: null };
9969
+ if (major === MajorType.TAG) return classifyTagged(genesisData);
9970
+ if (major === MajorType.ARRAY) return classifyArray(genesisData);
9971
+ return { envelope: "none_other", value: null };
9972
+ }
9973
+ function wrapToken(sdkToken) {
9974
+ const { envelope, value } = classifyValueEnvelope(sdkToken.genesis.data);
9975
+ const blob = {
9976
+ tokenId: HexConverter.encode(sdkToken.id.bytes),
9977
+ token: sdkToken.toCBOR()
9978
+ };
9979
+ return {
9980
+ sdkToken,
9981
+ blob,
9982
+ value,
9983
+ valueEnvelope: envelope,
9984
+ tokenType: HexConverter.encode(sdkToken.type.bytes)
9985
+ };
9986
+ }
9987
+ function assertMintableData(data) {
9988
+ try {
9989
+ classifyValueEnvelope(data);
9990
+ } catch (error) {
9991
+ throw invalid(
9992
+ `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.`
9993
+ );
9994
+ }
9995
+ }
9996
+
9856
9997
  // token-engine/SphereTokenEngine.ts
9857
9998
  var DEFAULT_PROOF_POLL_INTERVAL_MS = 300;
9858
9999
  var MAX_MINT_CONCURRENCY = 8;
@@ -9909,7 +10050,7 @@ var SphereTokenEngine = class {
9909
10050
  return sdkToken.latestTransaction.data;
9910
10051
  }
9911
10052
  const data = sdkToken.genesis.data;
9912
- if (data && this.isSpherePaymentData(data)) {
10053
+ if (data && classifyValueEnvelope(data).envelope === "sphere") {
9913
10054
  return SpherePaymentData.fromCBOR(data).memo;
9914
10055
  }
9915
10056
  return null;
@@ -9939,9 +10080,10 @@ var SphereTokenEngine = class {
9939
10080
  );
9940
10081
  const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
9941
10082
  const token = await Token.mint(certified, this.deps.verificationContext);
9942
- return this.wrapToken(token);
10083
+ return wrapToken(token);
9943
10084
  }
9944
10085
  async mintDataToken(params, options) {
10086
+ assertMintableData(params.data);
9945
10087
  const recipient = SignaturePredicate.create(params.recipientPubkey);
9946
10088
  const tokenType = params.tokenType ? new TokenType(params.tokenType) : TokenType.generate();
9947
10089
  const salt = params.salt ? TokenSalt.fromBytes(params.salt) : TokenSalt.generate();
@@ -9956,7 +10098,7 @@ var SphereTokenEngine = class {
9956
10098
  );
9957
10099
  const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
9958
10100
  const token = await Token.mint(certified, this.deps.verificationContext);
9959
- return this.wrapToken(token);
10101
+ return wrapToken(token);
9960
10102
  }
9961
10103
  async transfer(params, options) {
9962
10104
  this.assertOwned(params.token);
@@ -9979,13 +10121,19 @@ var SphereTokenEngine = class {
9979
10121
  );
9980
10122
  const certified = await transferTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
9981
10123
  const transferred = await params.token.sdkToken.transfer(certified, this.deps.verificationContext);
9982
- return this.wrapToken(transferred);
10124
+ return wrapToken(transferred);
9983
10125
  }
9984
10126
  async split(params, options) {
9985
10127
  this.assertOwned(params.token);
9986
10128
  if (params.outputs.length === 0) {
9987
10129
  throw new SphereError("Split requires at least one output", "VALIDATION_ERROR");
9988
10130
  }
10131
+ if (params.token.value === null) {
10132
+ throw new SphereError(
10133
+ `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.`,
10134
+ "VALIDATION_ERROR"
10135
+ );
10136
+ }
9989
10137
  const transferId = this.resolveTransferId(options);
9990
10138
  const requests = params.outputs.map(
9991
10139
  (o, i) => SplitTokenRequest.create(
@@ -10107,7 +10255,7 @@ var SphereTokenEngine = class {
10107
10255
  const proof = await this.submitSplitMintLeg(certData, mintTx, options);
10108
10256
  const certified = await mintTx.toCertifiedTransaction(this.deps.trustBase, this.deps.predicateVerifier, this.deps.unicityCertificateVerifier, proof);
10109
10257
  const token = await Token.mint(certified, this.deps.verificationContext);
10110
- return this.wrapToken(token);
10258
+ return wrapToken(token);
10111
10259
  }
10112
10260
  /**
10113
10261
  * Submit one split-mint leg. A `TRANSACTION_HASH_MISMATCH` on a mint leg is NOT a foreign spend
@@ -10165,7 +10313,7 @@ var SphereTokenEngine = class {
10165
10313
  "VALIDATION_ERROR"
10166
10314
  );
10167
10315
  }
10168
- return this.wrapToken(sdkToken);
10316
+ return wrapToken(sdkToken);
10169
10317
  }
10170
10318
  // ── internals ────────────────────────────────────────────────────────────────
10171
10319
  /**
@@ -10277,34 +10425,6 @@ var SphereTokenEngine = class {
10277
10425
  throw new SphereError("Cannot transfer a token not owned by this engine identity", "VALIDATION_ERROR");
10278
10426
  }
10279
10427
  }
10280
- /** Wrap an SDK token into a SphereToken: cache its blob (incl. stable tokenId) + decoded value. */
10281
- wrapToken(sdkToken) {
10282
- const data = sdkToken.genesis.data;
10283
- let value = null;
10284
- if (data && this.isSpherePaymentData(data)) {
10285
- try {
10286
- value = SpherePaymentData.fromCBOR(data).toValue();
10287
- } catch (err) {
10288
- throw new SphereError(
10289
- `Failed to decode token payment data: ${err instanceof Error ? err.message : String(err)}`,
10290
- "VALIDATION_ERROR"
10291
- );
10292
- }
10293
- }
10294
- const blob = {
10295
- tokenId: HexConverter.encode(sdkToken.id.bytes),
10296
- token: sdkToken.toCBOR()
10297
- };
10298
- return { sdkToken, blob, value };
10299
- }
10300
- /** True if the bytes are a SpherePaymentData envelope (value token) vs a raw data token. */
10301
- isSpherePaymentData(data) {
10302
- try {
10303
- return CborDeserializer.decodeTag(data).tag === SpherePaymentData.CBOR_TAG;
10304
- } catch {
10305
- return false;
10306
- }
10307
- }
10308
10428
  /**
10309
10429
  * Terminate the verification worker pool, if this engine was configured with
10310
10430
  * one. Idempotent — the pool's dispose only touches workers it spawned.
@@ -11700,6 +11820,14 @@ function createMachineStores(kv) {
11700
11820
  };
11701
11821
  }
11702
11822
 
11823
+ // modules/payments-v2/machine/payload-view.ts
11824
+ function splitOf(p) {
11825
+ return p.kind === "coin" ? p.split : void 0;
11826
+ }
11827
+ function coinIdOf(p) {
11828
+ return p.kind === "coin" ? p.coinId : void 0;
11829
+ }
11830
+
11703
11831
  // modules/payments-v2/machine/TransferMachine.ts
11704
11832
  var CERTIFY_WIDTH = 20;
11705
11833
  var KEEP_OPEN_CODES = /* @__PURE__ */ new Set([
@@ -11902,13 +12030,14 @@ var TransferMachine = class {
11902
12030
  const finished = await engine.transfer({ token: source, recipientPubkey }, opts);
11903
12031
  return { recipientBlob: finished.blob.token };
11904
12032
  }
11905
- const split = ctx.payload.split;
12033
+ const split = splitOf(ctx.payload);
12034
+ const splitCoinId = coinIdOf(ctx.payload);
11906
12035
  const { outputs } = await engine.split(
11907
12036
  {
11908
12037
  token: source,
11909
12038
  outputs: [
11910
- { recipientPubkey, coinId: ctx.payload.coinId, amount: BigInt(split.splitAmount) },
11911
- { recipientPubkey: this.deps.ownPubkey, coinId: ctx.payload.coinId, amount: BigInt(split.remainderAmount) }
12039
+ { recipientPubkey, coinId: splitCoinId, amount: BigInt(split.splitAmount) },
12040
+ { recipientPubkey: this.deps.ownPubkey, coinId: splitCoinId, amount: BigInt(split.remainderAmount) }
11912
12041
  ]
11913
12042
  },
11914
12043
  opts
@@ -12030,7 +12159,9 @@ var TransferMachine = class {
12030
12159
  const entry = {
12031
12160
  transferId: r.transferId,
12032
12161
  remainingAmount: undelivered.toString(),
12033
- coinId: r.payload.coinId,
12162
+ // '' only on a token-addressed intent, which is one leg: it either lands or
12163
+ // conflicts, so a shortfall row is unreachable there (settlePartial needs >1).
12164
+ coinId: coinIdOf(r.payload) ?? "",
12034
12165
  recipient: r.payload.recipient,
12035
12166
  committedTokenIds: committed.map((o) => o.op.sourceTokenId),
12036
12167
  createdAt: this.deps.now()
@@ -12056,7 +12187,8 @@ var TransferMachine = class {
12056
12187
  }
12057
12188
  async finishBookkeeping(engine, transferId, payload, phase, committed) {
12058
12189
  try {
12059
- const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
12190
+ const coinId = coinIdOf(payload);
12191
+ const committedAmount = coinId === void 0 ? "0" : await this.settledAmount(engine, coinId, committed);
12060
12192
  await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
12061
12193
  } catch {
12062
12194
  }
@@ -12099,8 +12231,11 @@ var TransferMachine = class {
12099
12231
  }
12100
12232
  };
12101
12233
  function conflictAmount(engine, payload, op, source) {
12102
- if (op.kind === "split") return BigInt(payload.split.splitAmount);
12103
- return source !== void 0 ? engine.balanceOf(source, payload.coinId) : 0n;
12234
+ const split = splitOf(payload);
12235
+ if (op.kind === "split") return BigInt(split.splitAmount);
12236
+ const coinId = coinIdOf(payload);
12237
+ if (coinId === void 0) return 0n;
12238
+ return source !== void 0 ? engine.balanceOf(source, coinId) : 0n;
12104
12239
  }
12105
12240
 
12106
12241
  // modules/payments-v2/machine/resume.ts
@@ -12207,6 +12342,14 @@ async function runOne(ctx, job, report) {
12207
12342
  report.failed.push(job.transferId);
12208
12343
  return;
12209
12344
  }
12345
+ if (job.payload.kind === "coinless" && !isCoinlessEnvelope(token.valueEnvelope)) {
12346
+ logger.warn(
12347
+ "PaymentsV2",
12348
+ `resume: token intent ${job.transferId} names a source carrying coin value \u2014 refusing`
12349
+ );
12350
+ report.failed.push(job.transferId);
12351
+ return;
12352
+ }
12210
12353
  mine.set(op.sourceTokenId, token);
12211
12354
  }
12212
12355
  try {
@@ -12237,6 +12380,28 @@ async function classifyResumeFailure(deps, stores, transferId, err, report) {
12237
12380
  }
12238
12381
  report.failed.push(transferId);
12239
12382
  }
12383
+ function validateCoinPayload(c) {
12384
+ if (typeof c.coinId !== "string" || typeof c.amount !== "string") {
12385
+ throw new SphereError("intent payload is missing coinId/amount", "VALIDATION_ERROR");
12386
+ }
12387
+ const s = c.split;
12388
+ if (s !== void 0 && (typeof s.tokenId !== "string" || typeof s.splitAmount !== "string" || typeof s.remainderAmount !== "string")) {
12389
+ throw new SphereError("intent payload split spec is malformed", "VALIDATION_ERROR");
12390
+ }
12391
+ return { ...c, kind: "coin" };
12392
+ }
12393
+ function validateCoinlessPayload(p) {
12394
+ if (p.direct?.length !== 1 || typeof p.direct[0] !== "string" || p.direct[0] === "") {
12395
+ throw new SphereError(
12396
+ "token intent payload must name exactly one source token",
12397
+ "VALIDATION_ERROR"
12398
+ );
12399
+ }
12400
+ if (p.split !== void 0) {
12401
+ throw new SphereError("token intent payload cannot carry a split", "VALIDATION_ERROR");
12402
+ }
12403
+ return { ...p, kind: "coinless", direct: [p.direct[0]] };
12404
+ }
12240
12405
  function validatePayload(raw) {
12241
12406
  const p = raw !== null && typeof raw === "object" ? raw : null;
12242
12407
  if (p === null || p.v !== 2 || !Array.isArray(p.direct) || p.direct.some((t) => typeof t !== "string")) {
@@ -12245,14 +12410,17 @@ function validatePayload(raw) {
12245
12410
  "VALIDATION_ERROR"
12246
12411
  );
12247
12412
  }
12248
- if (typeof p.recipient !== "string" || typeof p.coinId !== "string" || typeof p.amount !== "string") {
12249
- throw new SphereError("intent payload is missing recipient/coinId/amount", "VALIDATION_ERROR");
12413
+ if (typeof p.recipient !== "string") {
12414
+ throw new SphereError("intent payload is missing recipient", "VALIDATION_ERROR");
12250
12415
  }
12251
- const s = p.split;
12252
- if (s !== void 0 && (typeof s.tokenId !== "string" || typeof s.splitAmount !== "string" || typeof s.remainderAmount !== "string")) {
12253
- throw new SphereError("intent payload split spec is malformed", "VALIDATION_ERROR");
12416
+ const kind = p.kind ?? "coin";
12417
+ if (kind !== "coin" && kind !== "coinless") {
12418
+ throw new SphereError(
12419
+ `unsupported intent kind '${String(kind)}' \u2014 not resumable by this client`,
12420
+ "VALIDATION_ERROR"
12421
+ );
12254
12422
  }
12255
- return p;
12423
+ return kind === "coinless" ? validateCoinlessPayload(p) : validateCoinPayload(p);
12256
12424
  }
12257
12425
 
12258
12426
  // modules/payments-v2/convergence.ts
@@ -12458,13 +12626,23 @@ async function openRow(entry, journal, decryptPayload) {
12458
12626
  transferId: entry.transferId,
12459
12627
  kind: "open",
12460
12628
  recipient: typeof payload?.recipient === "string" ? payload.recipient : "",
12461
- coinId: typeof payload?.coinId === "string" ? payload.coinId : "",
12462
- amount: typeof payload?.amount === "string" ? payload.amount : "",
12629
+ ...subject(payload),
12463
12630
  legs: { certified, total: Math.max(total, certified) },
12464
12631
  deliveryPending: journal.length > 0,
12465
12632
  createdAt: entry.createdAt
12466
12633
  };
12467
12634
  }
12635
+ function subject(payload) {
12636
+ if (payload?.kind === "coinless") {
12637
+ const tokenId = payload.direct?.[0];
12638
+ return { coinId: "", amount: "", ...typeof tokenId === "string" ? { tokenId } : {} };
12639
+ }
12640
+ const coin = payload;
12641
+ return {
12642
+ coinId: typeof coin?.coinId === "string" ? coin.coinId : "",
12643
+ amount: typeof coin?.amount === "string" ? coin.amount : ""
12644
+ };
12645
+ }
12468
12646
  function journalOnlyRow(transferId, entries) {
12469
12647
  const certified = distinctOpCount(entries);
12470
12648
  return {
@@ -12494,6 +12672,160 @@ function distinctOpCount(entries) {
12494
12672
  return new Set(entries.map((entry) => entry.opIndex)).size;
12495
12673
  }
12496
12674
 
12675
+ // modules/payments-v2/inventory/token-data.ts
12676
+ async function readTokenData(deps, tokenId) {
12677
+ if (deps.view.stateHashOf(tokenId) === void 0) {
12678
+ throw new SphereError(`Token ${tokenId} is not in inventory`, "VALIDATION_ERROR");
12679
+ }
12680
+ const bytes = (await deps.storagePort.getBlobs([tokenId])).get(tokenId);
12681
+ if (bytes === void 0) {
12682
+ throw new SphereError(`Token ${tokenId} has no blob in storage`, "STORAGE_ERROR");
12683
+ }
12684
+ return deps.engine.readTokenData(await deps.engine.decodeToken({ tokenId, token: bytes }));
12685
+ }
12686
+
12687
+ // modules/payments-v2/machine/payload.ts
12688
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
12689
+ return {
12690
+ v: 2,
12691
+ kind: "coin",
12692
+ recipient: recipientPubkey,
12693
+ coinId: request.coinId,
12694
+ amount: request.amount,
12695
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
12696
+ direct: [...spend.plan.direct],
12697
+ ...spend.plan.split !== void 0 ? {
12698
+ split: {
12699
+ tokenId: spend.plan.split.tokenId,
12700
+ splitAmount: spend.plan.split.splitAmount.toString(),
12701
+ remainderAmount: spend.plan.split.remainderAmount.toString()
12702
+ }
12703
+ } : {},
12704
+ spentStates
12705
+ };
12706
+ }
12707
+ function messageOf(err) {
12708
+ return err instanceof Error ? err.message : String(err);
12709
+ }
12710
+ function buildCoinlessPayload(recipientPubkey, request, spentStates) {
12711
+ return {
12712
+ v: 2,
12713
+ kind: "coinless",
12714
+ recipient: recipientPubkey,
12715
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
12716
+ direct: [request.tokenId],
12717
+ spentStates
12718
+ };
12719
+ }
12720
+
12721
+ // modules/payments-v2/mint-params.ts
12722
+ function mintParams(recipientPubkey, coinId, amount) {
12723
+ return { recipientPubkey, value: { assets: [{ coinId, amount }] } };
12724
+ }
12725
+
12726
+ // modules/payments-v2/mint.ts
12727
+ function runMintUnderJournal(deps, input) {
12728
+ return mintUnderJournal(deps, input);
12729
+ }
12730
+ async function mintUnderJournal(deps, input) {
12731
+ const { mintId, coinId, amount } = input;
12732
+ const engine = deps.engine;
12733
+ const entry = {
12734
+ mintId,
12735
+ coinId,
12736
+ amount: amount.toString(),
12737
+ tokenId: "",
12738
+ createdAt: deps.now()
12739
+ };
12740
+ await deps.mintJournal.upsert(entry);
12741
+ let token;
12742
+ try {
12743
+ token = await engine.mint(mintParams(deps.ownPubkeyBytes, coinId, amount), { transferId: mintId });
12744
+ } catch (err) {
12745
+ deps.armHeartbeat();
12746
+ return { success: false, error: messageOf(err) };
12747
+ }
12748
+ if (token.blob.tokenId !== entry.tokenId) {
12749
+ await deps.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
12750
+ }
12751
+ try {
12752
+ await finalizeMint(deps, mintId, token, coinId, amount.toString());
12753
+ } catch (err) {
12754
+ deps.armHeartbeat();
12755
+ return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
12756
+ }
12757
+ return { success: true, tokenId: token.blob.tokenId };
12758
+ }
12759
+ async function finalizeMint(deps, mintId, token, coinId, amount) {
12760
+ const engine = deps.engine;
12761
+ const bytes = token.blob.token;
12762
+ const digest = bytesToHex3(sha256(bytes));
12763
+ const keys = await deps.storagePort.uploadBlobs([{ sha256: digest, bytes }]);
12764
+ const key = keys.get(digest);
12765
+ if (key === void 0) {
12766
+ throw new SphereError(`no upload key returned for mint blob ${digest}`, "STORAGE_ERROR");
12767
+ }
12768
+ await deps.storagePort.applyDelta({
12769
+ transferId: mintId,
12770
+ spent: [],
12771
+ added: [{ tokenId: token.blob.tokenId, key }]
12772
+ });
12773
+ await deps.recordMint({
12774
+ tokenId: token.blob.tokenId,
12775
+ assets: [{ coinId, amount }]
12776
+ });
12777
+ await deps.mintJournal.removeByKey(mintId);
12778
+ deps.noteHeldState(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
12779
+ deps.refreshView();
12780
+ }
12781
+
12782
+ // modules/payments-v2/send-coinless.ts
12783
+ async function materializeCoinlessSpend(deps, input) {
12784
+ const { engine } = deps;
12785
+ const tokenId = input.request.tokenId;
12786
+ const bytes = (await deps.storagePort.getBlobs([tokenId])).get(tokenId);
12787
+ if (bytes === void 0) {
12788
+ throw new SphereError(`Selected source ${tokenId} has no blob in storage`, "STORAGE_ERROR");
12789
+ }
12790
+ const token = await engine.decodeToken({ tokenId, token: bytes });
12791
+ if (!isCoinlessEnvelope(token.valueEnvelope)) {
12792
+ throw new SphereError(
12793
+ `Token ${tokenId} carries coin value and cannot be sent with sendCoinless \u2014 use send()`,
12794
+ "VALIDATION_ERROR"
12795
+ );
12796
+ }
12797
+ const keys = await engine.deliveryKeys(bytes);
12798
+ const payload = buildCoinlessPayload(input.recipientPubkey, input.request, {
12799
+ [tokenId]: { local: keys.stateHash, protocol: keys.stateHash }
12800
+ });
12801
+ const plan = {
12802
+ transferId: input.transferId,
12803
+ recipientPubkey: input.recipientPubkey,
12804
+ payload,
12805
+ ops: buildOps(payload),
12806
+ sources: /* @__PURE__ */ new Map([[tokenId, token]]),
12807
+ ...input.request.memo !== void 0 ? { memo: input.request.memo } : {}
12808
+ };
12809
+ return { transferId: input.transferId, coinId: "", plan, sourceIds: input.sourceIds, sourceTokens: [] };
12810
+ }
12811
+
12812
+ // modules/payments-v2/send-errors.ts
12813
+ function partialize(err, run) {
12814
+ if (run.delivered.length === 0) return err;
12815
+ return new PartialSendConflictError(
12816
+ "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.",
12817
+ run.firstPartialId ?? "",
12818
+ [...run.delivered],
12819
+ run.amount,
12820
+ err
12821
+ );
12822
+ }
12823
+ function stampTransferId(err, transferId) {
12824
+ if (err instanceof SphereError && isPossiblyCommittedSendOutcome(err) && err.transferId === void 0) {
12825
+ err.transferId = transferId;
12826
+ }
12827
+ }
12828
+
12497
12829
  // modules/payments-v2/recipient.ts
12498
12830
  var ATTENTION_RECIPIENT_NETWORK_UNVERIFIED = "recipient:network-unverified";
12499
12831
  async function requireSameNetworkRecipient(deps, identifier) {
@@ -12542,11 +12874,6 @@ async function splitOpIndex(deps, envelope) {
12542
12874
  return Array.isArray(payload.direct) ? payload.direct.length : 0;
12543
12875
  }
12544
12876
 
12545
- // modules/payments-v2/mint-params.ts
12546
- function mintParams(recipientPubkey, coinId, amount) {
12547
- return { recipientPubkey, value: { assets: [{ coinId, amount }] } };
12548
- }
12549
-
12550
12877
  // modules/payments-v2/prewarm-cache.ts
12551
12878
  var PrewarmCache = class _PrewarmCache {
12552
12879
  entries = /* @__PURE__ */ new Map();
@@ -12720,30 +13047,15 @@ function transferringToken(tokenId, coinId, amount, registry, now) {
12720
13047
  });
12721
13048
  }
12722
13049
 
12723
- // modules/payments-v2/machine/payload.ts
12724
- function buildPayload(recipientPubkey, request, spend, spentStates) {
12725
- return {
12726
- v: 2,
12727
- recipient: recipientPubkey,
12728
- coinId: request.coinId,
12729
- amount: request.amount,
12730
- ...request.memo !== void 0 ? { memo: request.memo } : {},
12731
- direct: [...spend.plan.direct],
12732
- ...spend.plan.split !== void 0 ? {
12733
- split: {
12734
- tokenId: spend.plan.split.tokenId,
12735
- splitAmount: spend.plan.split.splitAmount.toString(),
12736
- remainderAmount: spend.plan.split.remainderAmount.toString()
12737
- }
12738
- } : {},
12739
- spentStates
12740
- };
13050
+ // modules/payments-v2/history/History.ts
13051
+ function wireAssets(assets) {
13052
+ return assets.map((a) => ({ coinId: a.coinId, amount: a.amount }));
12741
13053
  }
12742
- function messageOf(err) {
12743
- return err instanceof Error ? err.message : String(err);
13054
+ function isPermanentReject(err) {
13055
+ const status = err?.status;
13056
+ if (typeof status !== "number") return false;
13057
+ return status >= 400 && status < 500 && status !== 408 && status !== 429;
12744
13058
  }
12745
-
12746
- // modules/payments-v2/history/History.ts
12747
13059
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
12748
13060
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
12749
13061
  var History = class {
@@ -12768,7 +13080,7 @@ var History = class {
12768
13080
  dedupKey: input.transferId,
12769
13081
  id: (this.deps.newId ?? randomUUID)(),
12770
13082
  type: "SENT",
12771
- assets: [{ coinId: input.coinId, amount: input.amount }],
13083
+ assets: wireAssets(input.assets),
12772
13084
  ts: this.ts(input.timestamp),
12773
13085
  transferId: input.transferId,
12774
13086
  ...this.wireTokenId(input.tokenId),
@@ -12781,7 +13093,7 @@ var History = class {
12781
13093
  dedupKey: `RECEIVED:${input.tokenId.toLowerCase()}:${input.stateHash.toLowerCase()}`,
12782
13094
  id: (this.deps.newId ?? randomUUID)(),
12783
13095
  type: "RECEIVED",
12784
- assets: [{ coinId: input.coinId, amount: input.amount }],
13096
+ assets: wireAssets(input.assets),
12785
13097
  ts: this.ts(input.timestamp),
12786
13098
  ...input.transferId !== void 0 ? { transferId: input.transferId } : {},
12787
13099
  ...this.wireTokenId(input.tokenId),
@@ -12794,7 +13106,7 @@ var History = class {
12794
13106
  dedupKey: `MINT:${input.tokenId.toLowerCase()}`,
12795
13107
  id: (this.deps.newId ?? randomUUID)(),
12796
13108
  type: "MINT",
12797
- assets: [{ coinId: input.coinId, amount: input.amount }],
13109
+ assets: wireAssets(input.assets),
12798
13110
  ts: this.ts(input.timestamp),
12799
13111
  ...this.wireTokenId(input.tokenId)
12800
13112
  }));
@@ -12805,7 +13117,10 @@ var History = class {
12805
13117
  try {
12806
13118
  await this.deps.client.postHistory([record]);
12807
13119
  } catch (err) {
12808
- this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
13120
+ this.deps.log?.(
13121
+ isPermanentReject(err) ? "history POST REJECTED \u2014 the record shape is refused; no retry can fix it" : "history POST failed (dedupKey makes retry safe)",
13122
+ err
13123
+ );
12809
13124
  return;
12810
13125
  }
12811
13126
  this.deps.emit("history:updated", this.toEntry(record));
@@ -12872,6 +13187,13 @@ var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
12872
13187
  function stateKey(tokenId, stateHash) {
12873
13188
  return `${tokenId}:${stateHash}`;
12874
13189
  }
13190
+ function unchanged(prev, item, tokenType) {
13191
+ return prev.seq === item.seq && prev.status === item.status && prev.stateHash === item.stateHash && tokenType === prev.tokenType;
13192
+ }
13193
+ function isCoinless(item, prev) {
13194
+ if (item.status !== "active") return prev?.coinless ?? false;
13195
+ return (item.assets?.length ?? 0) === 0;
13196
+ }
12875
13197
  var InventoryView = class {
12876
13198
  constructor(deps) {
12877
13199
  this.deps = deps;
@@ -12965,14 +13287,26 @@ var InventoryView = class {
12965
13287
  pool(coinId) {
12966
13288
  const out = [];
12967
13289
  for (const [tokenId, entry] of this.mirror) {
12968
- if (entry.status !== "active") continue;
12969
- if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) continue;
12970
- if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
13290
+ if (!this.isSpendable(tokenId, entry)) continue;
12971
13291
  const asset = entry.assets.find((a) => a.coinId === coinId);
12972
13292
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
12973
13293
  }
12974
13294
  return out;
12975
13295
  }
13296
+ /**
13297
+ * The eligibility gates every spend shares, coin or token — ONE definition, so a
13298
+ * single probe covers both verbs and neither can drift from the other.
13299
+ */
13300
+ isSpendable(tokenId, entry) {
13301
+ if (entry.status !== "active") return false;
13302
+ if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) return false;
13303
+ return !this.suspected.has(stateKey(tokenId, entry.stateHash));
13304
+ }
13305
+ /** #777: is this NAMED token a spendable COINLESS holding? Never a coin source. */
13306
+ spendableCoinless(tokenId) {
13307
+ const entry = this.mirror.get(tokenId);
13308
+ return entry !== void 0 && entry.coinless && this.isSpendable(tokenId, entry);
13309
+ }
12976
13310
  /**
12977
13311
  * Current mirrored state of an ACTIVE token, or undefined when it is unknown
12978
13312
  * or tombstoned. Exists so a cached blob can be state-scoped (F6): a blob is
@@ -12998,6 +13332,29 @@ var InventoryView = class {
12998
13332
  }
12999
13333
  return out;
13000
13334
  }
13335
+ /**
13336
+ * Coinless holdings (#777) — DISJOINT from tokens(): an entry is coinless
13337
+ * exactly when it is not, so no mirror row can appear in both reads.
13338
+ */
13339
+ coinless(registry) {
13340
+ const out = [];
13341
+ for (const [tokenId, entry] of this.mirror) {
13342
+ if (entry.status !== "active" || !entry.coinless) continue;
13343
+ const meta = entry.tokenType !== void 0 ? registry.getTypeMeta?.(entry.tokenType) : null;
13344
+ out.push({
13345
+ tokenId,
13346
+ ...entry.tokenType !== void 0 ? { tokenType: entry.tokenType } : {},
13347
+ ...meta ? { name: meta.name } : {},
13348
+ ...meta?.iconUrl != null ? { iconUrl: meta.iconUrl } : {},
13349
+ stateHash: entry.stateHash,
13350
+ transferring: this.held(tokenId),
13351
+ ...this.suspected.has(stateKey(tokenId, entry.stateHash)) ? { suspectedSpent: true } : {},
13352
+ createdAt: entry.createdAt,
13353
+ updatedAt: entry.updatedAt
13354
+ });
13355
+ }
13356
+ return out;
13357
+ }
13001
13358
  async assets(registry, price) {
13002
13359
  const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
13003
13360
  if (!price || raw.length === 0) return raw;
@@ -13069,14 +13426,19 @@ var InventoryView = class {
13069
13426
  applyOne(item, now) {
13070
13427
  const prev = this.mirror.get(item.tokenId);
13071
13428
  if (prev && item.seq < prev.seq) return false;
13072
- if (prev && prev.seq === item.seq && prev.status === item.status && prev.stateHash === item.stateHash) {
13073
- return false;
13074
- }
13429
+ const tokenType = item.tokenType ?? prev?.tokenType;
13430
+ if (prev && unchanged(prev, item, tokenType)) return false;
13075
13431
  this.mirror.set(item.tokenId, {
13076
13432
  stateHash: item.stateHash,
13077
13433
  seq: item.seq,
13078
13434
  status: item.status,
13079
- assets: item.assets ?? prev?.assets ?? [],
13435
+ // ONLY a tombstone inherits: it omits assets for an unrelated reason and
13436
+ // recoverRemoved must know the amount it restores. An ACTIVE row that omits
13437
+ // them is stating coinlessness, and inheriting there would keep stale assets
13438
+ // in tokens() while `coinless` is true — the row in BOTH reads (§16).
13439
+ assets: item.assets ?? (item.status === "removed" ? prev?.assets ?? [] : []),
13440
+ coinless: isCoinless(item, prev),
13441
+ ...tokenType !== void 0 ? { tokenType } : {},
13080
13442
  createdAt: prev?.createdAt ?? now,
13081
13443
  updatedAt: now
13082
13444
  });
@@ -13312,7 +13674,12 @@ async function screen(deps, engine, entry) {
13312
13674
  }
13313
13675
  return {
13314
13676
  kind: "accept",
13315
- record: { tokenId: keys.tokenId, stateHash: keys.stateHash, assets: toAssetAmounts(token) }
13677
+ record: {
13678
+ tokenId: keys.tokenId,
13679
+ stateHash: keys.stateHash,
13680
+ assets: toAssetAmounts(token),
13681
+ ...isCoinlessEnvelope2(token.valueEnvelope) ? { tokenType: token.tokenType } : {}
13682
+ }
13316
13683
  };
13317
13684
  }
13318
13685
  async function announce(deps, entry, record) {
@@ -13323,6 +13690,7 @@ async function announce(deps, entry, record) {
13323
13690
  tokenId: record.tokenId,
13324
13691
  stateHash: record.stateHash,
13325
13692
  assets: record.assets,
13693
+ ...record.tokenType !== void 0 ? { tokenType: record.tokenType } : {},
13326
13694
  ...entry.senderPubkey !== void 0 ? { senderPubkey: entry.senderPubkey } : {},
13327
13695
  ...entry.senderNametag !== void 0 ? { senderNametag: entry.senderNametag } : {},
13328
13696
  ...entry.memo !== void 0 ? { memo: entry.memo } : {},
@@ -13336,6 +13704,19 @@ async function announce(deps, entry, record) {
13336
13704
  senderPubkey: entry.senderPubkey ?? "",
13337
13705
  ...entry.senderNametag !== void 0 ? { senderNametag: entry.senderNametag } : {},
13338
13706
  tokens: record.assets.map((asset) => toUiToken(record.tokenId, asset, deps.registry, receivedAt)),
13707
+ // #777: named here rather than mapped from assets, which announced an EMPTY list.
13708
+ ...record.tokenType !== void 0 ? {
13709
+ coinless: [
13710
+ {
13711
+ tokenId: record.tokenId,
13712
+ tokenType: record.tokenType,
13713
+ stateHash: record.stateHash,
13714
+ transferring: false,
13715
+ createdAt: receivedAt,
13716
+ updatedAt: receivedAt
13717
+ }
13718
+ ]
13719
+ } : {},
13339
13720
  ...entry.memo !== void 0 ? { memo: entry.memo } : {},
13340
13721
  receivedAt
13341
13722
  };
@@ -13434,6 +13815,9 @@ function claimAck(entry) {
13434
13815
  function rejectAck(entry, reason) {
13435
13816
  return { deliveryId: entry.deliveryId, disposition: "rejected", reason, cursor: entry.cursor };
13436
13817
  }
13818
+ function isCoinlessEnvelope2(envelope) {
13819
+ return envelope.startsWith("none_");
13820
+ }
13437
13821
  function toAssetAmounts(token) {
13438
13822
  return (token.value?.assets ?? []).map((asset) => ({
13439
13823
  coinId: asset.coinId,
@@ -14001,6 +14385,37 @@ var SpendQueue = class {
14001
14385
  }
14002
14386
  return { kind: "queued", settled: this.enqueue(reservationId, request.coinId, amount) };
14003
14387
  }
14388
+ /**
14389
+ * #777: reserve a NAMED coinless source. Never queues — there is nothing to wait
14390
+ * for. A coin plan waits because some OTHER combination may free up; a named
14391
+ * token is either free now or held by a reservation that only its own transfer
14392
+ * can release, so queueing would block until a timeout on a spend that cannot
14393
+ * become possible.
14394
+ */
14395
+ planCoinless(reservationId, tokenId) {
14396
+ if (this.destroyed) {
14397
+ throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
14398
+ }
14399
+ const unproven = this.deps.ledger.unprovenReason();
14400
+ if (unproven !== null) {
14401
+ throw new SphereError(`Cannot spend yet: ${unproven}`, "SEND_SYNC_PENDING");
14402
+ }
14403
+ if (this.deps.spendableCoinless?.(tokenId) !== true) {
14404
+ throw new SphereError(
14405
+ `Token ${tokenId} is not a spendable coinless holding`,
14406
+ "VALIDATION_ERROR"
14407
+ );
14408
+ }
14409
+ const holder = this.deps.ledger.holderOf(tokenId);
14410
+ if (holder !== void 0) {
14411
+ throw new SphereError(
14412
+ `Token ${tokenId} is already reserved by transfer ${holder}`,
14413
+ "VALIDATION_ERROR"
14414
+ );
14415
+ }
14416
+ this.deps.ledger.reserve(reservationId, [{ tokenId }]);
14417
+ return { reservationId, plan: { direct: [tokenId] } };
14418
+ }
14004
14419
  /**
14005
14420
  * The sources `plan()` WOULD pick right now, reserving nothing and queueing
14006
14421
  * nothing — a read-only probe used to warm blobs while the user is still
@@ -14190,6 +14605,7 @@ function composeFacadeParts(deps, hooks) {
14190
14605
  const queue = new SpendQueue({
14191
14606
  ledger,
14192
14607
  getPool: (coinId) => view.pool(coinId),
14608
+ spendableCoinless: (tokenId) => view.spendableCoinless(tokenId),
14193
14609
  ...deps.workBudget !== void 0 ? { workBudget: deps.workBudget } : {}
14194
14610
  });
14195
14611
  const historyStore = new History({
@@ -14273,10 +14689,9 @@ function buildMachineDeps(deps, hooks, historyStore) {
14273
14689
  recordHistory: async ({ transferId, payload, committedAmount }) => {
14274
14690
  await historyStore.recordSent({
14275
14691
  transferId,
14276
- coinId: payload.coinId,
14277
- // §5.9: the SETTLED amount (machine-computed from the certified
14278
- // recipient blobs), never payload.amount — the plan.
14279
- amount: committedAmount,
14692
+ // §5.9: the SETTLED amount, never payload.amount — the plan. A token
14693
+ // spend moved no coin: `assets: []` + tokenId (wallet-api#151 / §10).
14694
+ ...payload.kind === "coinless" ? { assets: [], tokenId: payload.direct[0] } : { assets: [{ coinId: payload.coinId, amount: committedAmount }] },
14280
14695
  recipientPubkey: payload.recipient,
14281
14696
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
14282
14697
  });
@@ -14311,12 +14726,10 @@ function buildReceive(deps, hooks, historyStore, heldStates, refreshView) {
14311
14726
  });
14312
14727
  }
14313
14728
  async function recordReceived(historyStore, record) {
14314
- const first = record.assets[0];
14315
14729
  await historyStore.recordReceived({
14316
14730
  tokenId: record.tokenId,
14317
14731
  stateHash: record.stateHash,
14318
- coinId: first?.coinId ?? "",
14319
- amount: first?.amount ?? "0",
14732
+ assets: record.assets,
14320
14733
  ...record.senderPubkey !== void 0 ? { senderPubkey: record.senderPubkey } : {},
14321
14734
  ...record.senderNametag !== void 0 ? { senderNametag: record.senderNametag } : {},
14322
14735
  ...record.memo !== void 0 ? { memo: record.memo } : {},
@@ -14464,6 +14877,13 @@ var PaymentsFacade = class {
14464
14877
  tokens(filter) {
14465
14878
  return this.view.tokens(this.deps.registry, filter);
14466
14879
  }
14880
+ coinless() {
14881
+ return this.view.coinless(this.deps.registry);
14882
+ }
14883
+ tokenData(tokenId) {
14884
+ const deps = { engine: this.engine(), view: this.view, storagePort: this.deps.storagePort };
14885
+ return readTokenData(deps, tokenId);
14886
+ }
14467
14887
  history(page) {
14468
14888
  return this.historyStore.page(page ?? {});
14469
14889
  }
@@ -14488,6 +14908,9 @@ var PaymentsFacade = class {
14488
14908
  send(request) {
14489
14909
  return this.track(this.sendOutcome(request));
14490
14910
  }
14911
+ sendCoinless(request) {
14912
+ return this.track(this.sendCoinlessOutcome(request));
14913
+ }
14491
14914
  async receive() {
14492
14915
  const transfers = await this.track(this.receiveLoop.drainOnce());
14493
14916
  return { transfers };
@@ -14518,12 +14941,32 @@ var PaymentsFacade = class {
14518
14941
  nowMs() {
14519
14942
  return (this.deps.now ?? Date.now)();
14520
14943
  }
14944
+ mintDeps() {
14945
+ return {
14946
+ engine: this.engine(),
14947
+ mintJournal: this.machineStores.mintJournal,
14948
+ storagePort: this.deps.storagePort,
14949
+ recordMint: (input) => this.historyStore.recordMint(input),
14950
+ armHeartbeat: () => this.heartbeat.arm(),
14951
+ noteHeldState: (tokenId, stateHash) => void this.heldStates.set(tokenId, stateHash),
14952
+ refreshView: () => this.trackTail(this.view.delta()),
14953
+ ownPubkeyBytes: this.ownPubkeyBytes,
14954
+ now: () => this.nowMs()
14955
+ };
14956
+ }
14521
14957
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
14522
14958
  /** The ONE place a send() outcome is shaped: success emits in finishSend, a
14523
14959
  * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
14524
- async sendOutcome(request) {
14960
+ sendOutcome(request) {
14961
+ return this.runJob({ kind: "coin", request }, request.amount);
14962
+ }
14963
+ /** A token spend has no amount; '0' keeps the shortfall arithmetic total-free. */
14964
+ sendCoinlessOutcome(request) {
14965
+ return this.runJob({ kind: "coinless", request }, "0");
14966
+ }
14967
+ async runJob(job, amount) {
14525
14968
  const run = {
14526
- amount: request.amount,
14969
+ amount,
14527
14970
  delivered: [],
14528
14971
  sentTokens: [],
14529
14972
  tokenTransfers: [],
@@ -14533,7 +14976,7 @@ var PaymentsFacade = class {
14533
14976
  lastTransferId: ""
14534
14977
  };
14535
14978
  try {
14536
- return await this.sendWithPolicy(request, run);
14979
+ return await this.sendWithPolicy(job, run);
14537
14980
  } catch (err) {
14538
14981
  this.emitCleanFailure(err, run.lastTransferId);
14539
14982
  throw err;
@@ -14553,19 +14996,19 @@ var PaymentsFacade = class {
14553
14996
  };
14554
14997
  this.deps.emit("transfer:updated", failed);
14555
14998
  }
14556
- async sendWithPolicy(request, run) {
14557
- const recipient = await requireSameNetworkRecipient(this.deps, request.recipient);
14999
+ async sendWithPolicy(job, run) {
15000
+ const recipient = await requireSameNetworkRecipient(this.deps, job.request.recipient);
14558
15001
  for (let attempt = 0; ; attempt++) {
14559
15002
  let ctx;
14560
15003
  try {
14561
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
15004
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, job, run);
14562
15005
  } catch (err) {
14563
- throw this.partialize(err, run);
15006
+ throw partialize(err, run);
14564
15007
  }
14565
15008
  const disposition = await this.runAttempt(ctx);
14566
- if (disposition.kind === "rethrow") throw this.partialize(disposition.error, run);
15009
+ if (disposition.kind === "rethrow") throw partialize(disposition.error, run);
14567
15010
  if (disposition.kind === "retry-full") {
14568
- if (attempt >= MAX_RESELECT) throw this.partialize(disposition.error, run);
15011
+ if (job.kind === "coinless" || attempt >= MAX_RESELECT) throw partialize(disposition.error, run);
14569
15012
  continue;
14570
15013
  }
14571
15014
  if (disposition.kind === "success") {
@@ -14621,7 +15064,7 @@ var PaymentsFacade = class {
14621
15064
  async disposeFailedAttempt(ctx, err) {
14622
15065
  if (isPossiblyCommittedSendOutcome(err)) {
14623
15066
  this.settleKeepOpen(ctx);
14624
- this.stampTransferId(err, ctx.transferId);
15067
+ stampTransferId(err, ctx.transferId);
14625
15068
  return { kind: "rethrow", error: err };
14626
15069
  }
14627
15070
  const backstop = await this.machineStores.backstop.getByKey(ctx.transferId);
@@ -14655,7 +15098,7 @@ var PaymentsFacade = class {
14655
15098
  async disposeConvergeFailure(ctx, err) {
14656
15099
  if (isPossiblyCommittedSendOutcome(err)) {
14657
15100
  this.settleKeepOpen(ctx);
14658
- this.stampTransferId(err, ctx.transferId);
15101
+ stampTransferId(err, ctx.transferId);
14659
15102
  return { kind: "rethrow", error: err };
14660
15103
  }
14661
15104
  if (classifyError(err) === "conflict") {
@@ -14666,9 +15109,23 @@ var PaymentsFacade = class {
14666
15109
  }
14667
15110
  return { kind: "rethrow", error: err };
14668
15111
  }
14669
- async planAndMaterialize(recipientPubkey, request, run) {
15112
+ async planAndMaterialize(recipientPubkey, job, run) {
14670
15113
  const transferId = this.newId();
14671
15114
  run.lastTransferId = transferId;
15115
+ if (job.kind === "coinless") {
15116
+ const spend2 = this.queue.planCoinless(transferId, job.request.tokenId);
15117
+ const sourceIds2 = this.markPlanned(transferId, "", spend2);
15118
+ try {
15119
+ return await materializeCoinlessSpend(
15120
+ { engine: this.engine(), storagePort: this.deps.storagePort },
15121
+ { transferId, recipientPubkey, request: job.request, sourceIds: sourceIds2 }
15122
+ );
15123
+ } catch (err) {
15124
+ this.settleFailure(transferId, "", sourceIds2);
15125
+ throw err;
15126
+ }
15127
+ }
15128
+ const request = { ...job.request, amount: run.amount };
14672
15129
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
14673
15130
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
14674
15131
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -14802,22 +15259,6 @@ var PaymentsFacade = class {
14802
15259
  return result;
14803
15260
  }
14804
15261
  /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
14805
- partialize(err, run) {
14806
- if (run.delivered.length === 0) return err;
14807
- return new PartialSendConflictError(
14808
- "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.",
14809
- run.firstPartialId ?? "",
14810
- run.delivered,
14811
- run.amount,
14812
- err
14813
- );
14814
- }
14815
- /** #441: possibly-committed errors must carry the transferId for the settling journal. */
14816
- stampTransferId(err, transferId) {
14817
- if (err instanceof SphereError && isPossiblyCommittedSendOutcome(err) && err.transferId === void 0) {
14818
- err.transferId = transferId;
14819
- }
14820
- }
14821
15262
  async softAbort(transferId) {
14822
15263
  try {
14823
15264
  await this.deps.client.abortIntent(transferId);
@@ -14833,57 +15274,11 @@ var PaymentsFacade = class {
14833
15274
  const mintId = this.newId();
14834
15275
  this.activeMoneyOps.add(mintId);
14835
15276
  try {
14836
- return await this.mintUnderJournal(mintId, coinId, amount);
15277
+ return await runMintUnderJournal(this.mintDeps(), { mintId, coinId, amount });
14837
15278
  } finally {
14838
15279
  this.activeMoneyOps.delete(mintId);
14839
15280
  }
14840
15281
  }
14841
- async mintUnderJournal(mintId, coinId, amount) {
14842
- const engine = this.engine();
14843
- const entry = {
14844
- mintId,
14845
- coinId,
14846
- amount: amount.toString(),
14847
- tokenId: "",
14848
- createdAt: this.nowMs()
14849
- };
14850
- await this.machineStores.mintJournal.upsert(entry);
14851
- let token;
14852
- try {
14853
- token = await engine.mint(mintParams(this.ownPubkeyBytes, coinId, amount), { transferId: mintId });
14854
- } catch (err) {
14855
- this.heartbeat.arm();
14856
- return { success: false, error: messageOf(err) };
14857
- }
14858
- if (token.blob.tokenId !== entry.tokenId) {
14859
- await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
14860
- }
14861
- try {
14862
- await this.finalizeMint(engine, mintId, token, coinId, amount.toString());
14863
- } catch (err) {
14864
- this.heartbeat.arm();
14865
- return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
14866
- }
14867
- return { success: true, tokenId: token.blob.tokenId };
14868
- }
14869
- async finalizeMint(engine, mintId, token, coinId, amount) {
14870
- const bytes = token.blob.token;
14871
- const digest = bytesToHex3(sha256(bytes));
14872
- const keys = await this.deps.storagePort.uploadBlobs([{ sha256: digest, bytes }]);
14873
- const key = keys.get(digest);
14874
- if (key === void 0) {
14875
- throw new SphereError(`no upload key returned for mint blob ${digest}`, "STORAGE_ERROR");
14876
- }
14877
- await this.deps.storagePort.applyDelta({
14878
- transferId: mintId,
14879
- spent: [],
14880
- added: [{ tokenId: token.blob.tokenId, key }]
14881
- });
14882
- await this.historyStore.recordMint({ tokenId: token.blob.tokenId, coinId, amount });
14883
- await this.machineStores.mintJournal.removeByKey(mintId);
14884
- this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
14885
- this.trackTail(this.view.delta());
14886
- }
14887
15282
  /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */
14888
15283
  async replayMints() {
14889
15284
  let resolved = 0;
@@ -14915,7 +15310,7 @@ var PaymentsFacade = class {
14915
15310
  if (token.blob.tokenId !== entry.tokenId) {
14916
15311
  await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
14917
15312
  }
14918
- await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount);
15313
+ await finalizeMint(this.mintDeps(), entry.mintId, token, entry.coinId, entry.amount);
14919
15314
  return true;
14920
15315
  }
14921
15316
  async tokenInServerInventory(tokenId) {