@unconfirmed/sui-effect 0.1.1 → 0.1.3

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 (74) hide show
  1. package/AGENTS.md +34 -11
  2. package/CHANGELOG.md +77 -0
  3. package/LLMS.md +630 -704
  4. package/README.md +175 -9
  5. package/dist/domain/bcs.d.ts.map +1 -1
  6. package/dist/domain/bcs.js +25 -9
  7. package/dist/domain/bcs.js.map +1 -1
  8. package/dist/domain/errors.d.ts +178 -40
  9. package/dist/domain/errors.d.ts.map +1 -1
  10. package/dist/domain/errors.js +271 -37
  11. package/dist/domain/errors.js.map +1 -1
  12. package/dist/domain/executed.d.ts +71 -2
  13. package/dist/domain/executed.d.ts.map +1 -1
  14. package/dist/domain/executed.js +210 -9
  15. package/dist/domain/executed.js.map +1 -1
  16. package/dist/domain/journal-entry.d.ts +37 -37
  17. package/dist/domain/journal-entry.js +1 -1
  18. package/dist/domain/schemas.d.ts +172 -65
  19. package/dist/domain/schemas.d.ts.map +1 -1
  20. package/dist/domain/schemas.js +131 -32
  21. package/dist/domain/schemas.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/internal.d.ts +1 -1
  27. package/dist/internal.d.ts.map +1 -1
  28. package/dist/internal.js +1 -1
  29. package/dist/internal.js.map +1 -1
  30. package/dist/script.d.ts +1 -1
  31. package/dist/script.d.ts.map +1 -1
  32. package/dist/script.js +1 -1
  33. package/dist/script.js.map +1 -1
  34. package/dist/services/Script.d.ts +42 -0
  35. package/dist/services/Script.d.ts.map +1 -1
  36. package/dist/services/Script.js +112 -77
  37. package/dist/services/Script.js.map +1 -1
  38. package/dist/services/Signer.d.ts +32 -7
  39. package/dist/services/Signer.d.ts.map +1 -1
  40. package/dist/services/Signer.js +69 -10
  41. package/dist/services/Signer.js.map +1 -1
  42. package/dist/services/SubmitConfig.d.ts +3 -22
  43. package/dist/services/SubmitConfig.d.ts.map +1 -1
  44. package/dist/services/SubmitConfig.js +54 -9
  45. package/dist/services/SubmitConfig.js.map +1 -1
  46. package/dist/services/Sui.d.ts +42 -1
  47. package/dist/services/Sui.d.ts.map +1 -1
  48. package/dist/services/Sui.js +46 -17
  49. package/dist/services/Sui.js.map +1 -1
  50. package/dist/services/SuiCore.d.ts.map +1 -1
  51. package/dist/services/SuiCore.js +47 -33
  52. package/dist/services/SuiCore.js.map +1 -1
  53. package/dist/services/SuiCoreFake.d.ts +47 -4
  54. package/dist/services/SuiCoreFake.d.ts.map +1 -1
  55. package/dist/services/SuiCoreFake.js +193 -22
  56. package/dist/services/SuiCoreFake.js.map +1 -1
  57. package/dist/services/Tx.d.ts +236 -402
  58. package/dist/services/Tx.d.ts.map +1 -1
  59. package/dist/services/Tx.js +205 -10
  60. package/dist/services/Tx.js.map +1 -1
  61. package/dist/testing.d.ts +1 -0
  62. package/dist/testing.d.ts.map +1 -1
  63. package/dist/testing.js +9 -0
  64. package/dist/testing.js.map +1 -1
  65. package/dist/tx.d.ts +1 -1
  66. package/dist/tx.d.ts.map +1 -1
  67. package/dist/tx.js +1 -1
  68. package/dist/tx.js.map +1 -1
  69. package/docs/extensions.md +608 -19
  70. package/examples/extension-template/src/Escrow.ts +1 -1
  71. package/examples/extension-template/src/errors.ts +29 -0
  72. package/examples/extension-template/src/schema.ts +8 -17
  73. package/examples/extension-template/test/escrow.test.ts +44 -0
  74. package/package.json +1 -1
@@ -168,6 +168,18 @@ export class EscrowNotFound extends Schema.TaggedError<EscrowNotFound>()(
168
168
  { escrowId: ObjectId }
169
169
  ) {
170
170
  readonly outcome: Outcome = "not_applied"
171
+
172
+ /**
173
+ * `Schema.TaggedError` leaves `.message` empty, so anything surfacing
174
+ * `error.message` — a log line, a `catch` in a consumer's UI,
175
+ * `SuiError.toJson` — shows nothing unless the class supplies one. This is
176
+ * the idiom sui-effect's own errors use, and the reason every error here has
177
+ * one: define a getter over the fields, never a `message` schema field you
178
+ * then have to pass to every constructor.
179
+ */
180
+ override get message(): string {
181
+ return `no escrow ${this.escrowId}`
182
+ }
171
183
  }
172
184
  ```
173
185
 
@@ -182,6 +194,15 @@ export class EscrowSettlementUnknown extends Schema.TaggedError<EscrowSettlement
182
194
  }
183
195
  ```
184
196
 
197
+ **Every error needs a real `.message`.** `Schema.TaggedError` leaves it empty,
198
+ so `error.message` is `""` for a class that supplies nothing — in a log line, in
199
+ a consumer's `catch`, and in `SuiError.toJson`, which emits no `message` key at
200
+ all for such an error. An `override get message()` over the fields is the usual
201
+ answer and is what sui-effect's own eighteen classes do: it is a getter, so it
202
+ stays out of the encoding and out of the constructor. A `message` **schema**
203
+ field is for the case where the sentence comes from somewhere else, as
204
+ `EscrowSettlementUnknown`'s comes from the settlement service.
205
+
185
206
  `outcome` is the axis a wrapper script acts on: `"applied"` (it is on chain, gas
186
207
  was charged, do not retry), `"unknown"` (reconcile before doing anything else),
187
208
  `"not_applied"` (nothing happened, safe to retry). `SuiError.outcome` reads the
@@ -198,6 +219,61 @@ nothing happened — answering `"not_applied"` would tell the documented retry
198
219
  idiom to send again. The `"not_applied"` default is for @unconfirmed/sui-effect's own
199
220
  taxonomy, not for yours.
200
221
 
222
+ **`outcome` can be a schema field instead of a class field, and there is a
223
+ reason to prefer it.** `readonly outcome: Outcome = "not_applied"` beside the
224
+ schema — what `src/errors.ts` does, and what three converted packages copy — is
225
+ a class field, so it is invisible to the schema: it does not appear in the
226
+ error's JSON Schema, `Schema.decodeUnknownSync(MyError)` on a logged line does
227
+ not get it back, and `SuiError.toJson` has to read it off the instance and patch
228
+ it in. `Schema.tag` makes it a real field with a fixed value:
229
+
230
+ <!-- inline -->
231
+
232
+ ```ts
233
+ export class EscrowUnsupportedNetwork extends Schema.TaggedError<EscrowUnsupportedNetwork>()(
234
+ "escrow/EscrowUnsupportedNetwork",
235
+ { network: Schema.String, outcome: Schema.tag("not_applied") }
236
+ ) {}
237
+ ```
238
+
239
+ `new EscrowUnsupportedNetwork({ network: "devnet" })` still takes only
240
+ `network` — a `Schema.tag` field supplies itself — while `outcome` is present on
241
+ the instance, encoded by `SuiError.toJson` with no patch-back, decodable again,
242
+ and visible to `Schema.is`. `SuiError.outcome` and `Script.exitCode` read it
243
+ exactly as they read the class field, so the two forms are interchangeable at
244
+ every call site and **the class-field form keeps working**; use `Schema.tag` for
245
+ errors you are writing now.
246
+
247
+ **Tag strings are namespaced by whoever defined them, and sui-effect's are
248
+ not.** @unconfirmed/sui-effect's own tags are bare — `TransportError`,
249
+ `ObjectNotFound`, `DecodeError` — while an extension prefixes its own, so a
250
+ platform's tag is `partyos/PartyNotFound` or `EscrowNotFound` depending on the
251
+ convention that package chose. `Effect.catchTag` matches the string exactly, so
252
+ **copy the tag from the installed package**, never from a migration note or
253
+ from memory: a `catchTag("PartyNotFound")` against a package that ships
254
+ `partyos/PartyNotFound` compiles (the union is open at the string level in
255
+ neither direction you expect) or silently never fires, and a README that
256
+ disagrees with the class is the single most common conversion bug. When you
257
+ rename or re-prefix a tag, that is a breaking change and belongs in your
258
+ changelog with the old and the new string side by side.
259
+
260
+ **`DecodeError` carries a `kind`, and that is what to branch on.** `"type"` is
261
+ "this object is not of the type I asked for" — the one a read service answers
262
+ with a 404 or a `filter`. `"bytes"` is "the type matched and the BCS did not
263
+ parse", which is a layout mismatch between your package and the chain and must
264
+ never be swallowed. `"shape"` is a domain schema refusing an already-parsed
265
+ value. The `issue` string is for a human and its wording changes between
266
+ releases; branching on it is how a foreign-object 404 quietly starts hiding a
267
+ real decode bug.
268
+
269
+ **`SuiError.outcome` takes a phase.** The default (`"post-submit"`) answers
270
+ `"unknown"` for a tag it does not recognise, because after a submission an
271
+ unfamiliar error is not evidence that nothing was sent. In a `catchAll` that can
272
+ only be reached **before** a submission — validation, a build, a signature —
273
+ pass `{ phase: "pre-submit" }` and an unrecognised tag becomes `"not_applied"`,
274
+ which is true by construction there. `SuiError.isTaxonomy(error)` is the same
275
+ question one level lower.
276
+
201
277
  Do not invent an error for something the taxonomy already names. A node that
202
278
  could not be reached is a `TransportError`; bytes that did not decode are a
203
279
  `DecodeError`; a transaction that aborted on chain is an `ExecutionFailed`; a
@@ -265,7 +341,7 @@ export const SettlementContent = (typeOrigin: string) =>
265
341
  ).pipe(
266
342
  Schema.decodeTo(
267
343
  Settlement,
268
- SchemaTransformation.transformOrFail<SettlementParts, typeof SettlementBcs.$inferType>({
344
+ SchemaTransformation.transformOrFail<typeof Settlement.Encoded, typeof SettlementBcs.$inferType>({
269
345
  decode: (fields, options) =>
270
346
  // `transformOrFail`, not `transform`, because one of these mappings can
271
347
  // fail: a `u64` of milliseconds is not necessarily a time. A `transform`
@@ -310,21 +386,22 @@ The domain class is an ordinary `Schema.Class`:
310
386
  <!-- from: examples/extension-template/src/schema.ts -->
311
387
 
312
388
  ```ts
313
- export class Settlement extends Schema.Class<Settlement>("Settlement")({
389
+ export class Settlement extends Schema.Class<Settlement>("escrow/Settlement")({
314
390
  escrowId: ObjectId,
315
391
  settledAt: Schema.DateTimeUtc,
316
392
  claimedBy: SuiAddress
317
393
  }) {}
318
394
  ```
319
395
 
320
- **The halfway shape must be an explicit interface.** The transformation's source
321
- type — what `decode` produces and `encode` consumes — is written out as its own
322
- interface, as `src/schema.ts` does. Reaching for `typeof Settlement.Encoded` or
323
- `typeof Settlement.Type` instead looks equivalent and is not: those name the
324
- class's *own* two sides, which inverts the direction the transformation is being
325
- inferred in, and the result does not compile with an error about the wrong
326
- side of the transformation, several frames away from the line that caused it.
327
- Write the interface.
396
+ **The halfway shape is the target's `Encoded` side.** The transformation's
397
+ source type — what `decode` produces and `encode` consumes — is
398
+ `typeof Settlement.Encoded`, and `src/schema.ts` spells it exactly that way. A
399
+ hand-written interface with the same fields is equivalent and compiles too, but
400
+ it is a second copy of the class's shape that can drift from it, so prefer the
401
+ `Encoded` side. What does **not** fit is `typeof Settlement.Type`: that is the
402
+ *instance* side, which inverts the direction the transformation is being
403
+ inferred in, and the error arrives several frames away from the line that caused
404
+ it.
328
405
 
329
406
  `decode` produces the target's field shape and the target schema does the rest,
330
407
  so the `ObjectId` and `SuiAddress` brands are checked as part of the same
@@ -558,7 +635,8 @@ out of fifty is not a failed read. Two idioms, and you should pick deliberately:
558
635
  `results.filter(Result.isSuccess).map((result) => result.success)`, or
559
636
  `Result.getOrElse(result, () => fallback)` per item, or a `Map` keyed by id so
560
637
  a caller can ask about one;
561
- - **hard** — every id must be there: `sui.getObjectsOrFail(ids, opts)`, which
638
+ - **hard** — every id must be there: `sui.getObjectsStrict(ids, opts)` (named
639
+ `getObjectsOrFail` before 0.1.2, and still reachable under that name), which
562
640
  fails with the first item's error (`ObjectNotFound`, `ObjectDeleted`,
563
641
  `ObjectUnavailable` or `DecodeError`) and otherwise hands back the objects in
564
642
  the order of the ids.
@@ -718,6 +796,66 @@ An extension whose two parties cannot both sign in one process — the sponsor i
718
796
  a remote service, the sender is a wallet — uses the explicit lifecycle instead:
719
797
  `Tx.build`, `Tx.sign`, hand the bytes over, `Tx.cosign`, `Tx.submit`.
720
798
 
799
+ ### A signer double, for tests
800
+
801
+ `Signer.fromSdkSigner` reads `toSuiAddress()` and `getKeyScheme()` **at
802
+ construction** and rejects a value that has neither, so a partial double is a
803
+ `TypeError` where it used to be `scheme: undefined` and silence. A double is
804
+ `Signer.remote`, which takes exactly what a signer is:
805
+
806
+ <!-- inline -->
807
+
808
+ ```ts
809
+ const doubleSigner = (address: SuiAddress): Signer =>
810
+ Signer.remote({
811
+ address,
812
+ scheme: "ED25519",
813
+ signTransaction: () => Effect.succeed("AAAA…" as string) // any non-empty string decodes
814
+ })
815
+ ```
816
+
817
+ For a test that actually submits, use a real keypair
818
+ (`Signer.fromKeypair(Ed25519Keypair.fromSecretKey(new Uint8Array(32).fill(7)))`):
819
+ the fake checks that the signatures cover the addresses the bytes name, and a
820
+ fabricated signature only passes the count check.
821
+
822
+ ### Sponsored by an external service
823
+
824
+ `Tx.run`'s `sponsor` and `Tx.cosign` both want a local `Signer`. A service that
825
+ co-signs **and submits** on your behalf — a relayer, a sponsorship API — has
826
+ neither, and `Tx.submit` is not the last step either, because the service sends
827
+ the bytes. The supported sequence is:
828
+
829
+ <!-- inline -->
830
+
831
+ ```ts
832
+ const built = yield* Tx.build(Tx.sponsored({ sender, gasOwner })(recipe), {
833
+ sender,
834
+ gasOwner
835
+ })
836
+ const signed = yield* Tx.sign(built, signer)
837
+ // The wire form: base64 bytes, and the serialized signature string the SDK
838
+ // produces. `signed.signatures[0]` is the sender's; the service adds its own.
839
+ const envelope = {
840
+ transactionBlockBytes: toBase64(signed.bytes),
841
+ signature: signed.signatures[0]!
842
+ }
843
+ const reply = yield* callTheSponsor(envelope)
844
+ // Two ways to end, and both are supported:
845
+ // the service returned an execute envelope — decode it and keep the accessors
846
+ const executed = yield* Executed.fromPartial(reply)
847
+ // or it returned only a digest — ask the chain yourself
848
+ const settled = yield* Tx.reconcile(signed)
849
+ ```
850
+
851
+ Three things to know. The digest does not change when the sponsor adds its
852
+ signature, so `signed.digest` is the digest to record and to reconcile by.
853
+ `Tx.reconcile(signed)` — passing the `Signed`, not the bare digest — is what
854
+ gets the evidence rules, so a service that never sent the bytes ends as
855
+ `NotApplied` rather than as an eternal `SubmissionUnknown`. And whatever the
856
+ service returns is a **reduced** envelope: see "Relay and sponsor envelopes"
857
+ below, and use `Executed.fromPartial` rather than `Schema.decodeUnknownSync`.
858
+
721
859
  ## 6. Layers
722
860
 
723
861
  Following the house convention: `layer(opts)` for the live one, `layerConfig`
@@ -852,6 +990,11 @@ export class EscrowUnsupportedNetwork extends Schema.TaggedError<EscrowUnsupport
852
990
  { network: Schema.String }
853
991
  ) {
854
992
  readonly outcome: Outcome = "not_applied"
993
+
994
+ /** See {@link EscrowNotFound.message}: a getter, not a schema field. */
995
+ override get message(): string {
996
+ return `this release bundles no escrow deployment for ${this.network}`
997
+ }
855
998
  }
856
999
  ```
857
1000
 
@@ -956,6 +1099,35 @@ chain. What catches it is the chain itself: `Tx.build` stamps that id on the
956
1099
  transaction's expiration and a validator refuses bytes signed for another chain.
957
1100
  Register lazily when the assertion is what you want.
958
1101
 
1102
+ **Do not over-invest in testing the mismatch checks.** A consumer that reads
1103
+ both from one deployment manifest — `network: deployment.network` on the client
1104
+ and `chainId: deployment.chainIdentifier` on the registration — cannot make the
1105
+ network check and the chain-id check fire except by supplying an inconsistent
1106
+ pair on purpose. One test that a wrong `chainId` throws is worth having; a
1107
+ matrix over the combinations is testing the manifest, not the code.
1108
+
1109
+ **In a browser, `warm` throws into your import graph.** The natural place for a
1110
+ registration in an SPA is module scope, and a throw there takes down the whole
1111
+ module — no error boundary, no console line a user can act on, just a blank
1112
+ page. Two patterns work: register lazily and `await client.ext.$ready()` in a
1113
+ boot step that has somewhere to put the failure; or keep the `warm`
1114
+ registration and wrap it, exporting the error instead of throwing it:
1115
+
1116
+ <!-- inline -->
1117
+
1118
+ ```ts
1119
+ // sui-client.ts
1120
+ export let bootError: unknown
1121
+ export const client = (() => {
1122
+ try {
1123
+ return baseClient.$extend(escrow({ warm: { chainId } }))
1124
+ } catch (cause) {
1125
+ bootError = cause
1126
+ return baseClient.$extend(escrow({})) // lazy: every call rejects, nothing throws
1127
+ }
1128
+ })()
1129
+ ```
1130
+
959
1131
  **Thread the chain id through your registration options**, the way
960
1132
  `src/extension.ts` and `src/Platform.ts` both do, rather than relying on the
961
1133
  built-in table. It is what makes the face work on `devnet` and `localnet`, and
@@ -1269,7 +1441,7 @@ const feeCollector = sui.core
1269
1441
  Effect.flatMap((raw) =>
1270
1442
  decodeAddress(raw).pipe(
1271
1443
  Effect.mapError((issue) =>
1272
- new DecodeError({ expectedType: "SuiAddress", issue: issue.message })
1444
+ new DecodeError({ expectedType: "SuiAddress", kind: "shape", issue: issue.message })
1273
1445
  )
1274
1446
  )
1275
1447
  ),
@@ -1511,6 +1683,91 @@ test("a retryable transport failure re-sends the identical bytes", async () => {
1511
1683
  })
1512
1684
  ```
1513
1685
 
1686
+ ### Scripting a submit, and the traps in it
1687
+
1688
+ Four things about the fake decide whether a submit test means anything.
1689
+
1690
+ **`Tx.submit` asks `getTransaction` when it cannot get a clean answer.** A
1691
+ retryable transport failure, a timeout, anything that leaves the outcome open
1692
+ ends in `Tx.reconcile`, and `reconcile`'s first move is a `getTransaction` for
1693
+ the digest. So a script whose `getTransaction` is a `succeed` is saying "this
1694
+ transaction is on chain" — and a submit test that did not mean that gets a
1695
+ **success** out of a path it was trying to prove fails. Unless the test is
1696
+ modelling a landed transaction, script `getTransaction: [FakeOutcome.notFound()]`
1697
+ and let the evidence rules run.
1698
+
1699
+ **A sponsored submit needs `Tx.cosign` first.** The fake refuses a submission
1700
+ whose signatures do not cover the addresses the bytes name — by count, and by
1701
+ the addresses recovered from the signatures themselves — the way a validator
1702
+ does, with a gRPC `INVALID_ARGUMENT`. `Tx.submit` reports that one outright as a
1703
+ `TransportError { retryable: false, status: "INVALID_ARGUMENT" }` rather than
1704
+ reconciling it, because the node refused the request and nothing was executed.
1705
+ So a sponsored-flow test builds with `Tx.sponsored`, signs with the sender,
1706
+ **`Tx.cosign`s with the sponsor**, and only then submits; `Tx.run(recipe, {
1707
+ signer, sponsor })` does all of that itself. Assert it, too — the signature
1708
+ count is on the recorded call:
1709
+
1710
+ <!-- inline -->
1711
+
1712
+ ```ts
1713
+ const sent = yield* SuiTest.calls("executeTransaction")
1714
+ expect(sent).toHaveLength(1)
1715
+ expect((sent[0]!.options as { signatures: ReadonlyArray<string> }).signatures)
1716
+ .toHaveLength(2)
1717
+ ```
1718
+
1719
+ **`FakeOutcome.failWith` takes either reason shape.** The SDK's
1720
+ `SuiClientTypes.ExecutionError` is the wire shape — a top-level `message` and an
1721
+ `abortCode` **string** — and sui-effect's decoded `ExecutionReason` has a
1722
+ `bigint` `abortCode` and no `message`. Both are accepted and the second is
1723
+ encoded for you; anything that is neither throws where the fixture is written,
1724
+ naming both shapes, instead of failing a decode several calls later on an
1725
+ unrelated method.
1726
+
1727
+ **Which script slot drives the build's simulate.** `Tx.build` always simulates,
1728
+ and on the fake that simulate is the **resolver's**: it is recorded (so
1729
+ `SuiTest.calls("simulateTransaction")` counts it) and it is answered by
1730
+ `buildSimulate` when that script has entries, and otherwise by the ordered
1731
+ `simulate` script. So `simulate: [FakeOutcome.failWith(...)]` makes `Tx.build`
1732
+ and `Tx.run` fail with `SimulationFailed` the way a node would, and
1733
+ `buildSimulate` is the slot to use when a test needs the build's simulate and
1734
+ an explicit `sui.simulate` to answer differently.
1735
+
1736
+ **`layerTest` asserts the built-in chain id for `mainnet` and `testnet`.**
1737
+ It is `Sui.layerNoDeps`, the production layer, so a script that says
1738
+ `network: "mainnet"` and a `chainId` of its own fails to build with
1739
+ `NetworkMismatch`. Use `network: "localnet"` (the default, which asserts
1740
+ nothing) in fixtures, or the real bundled identifier for the network you named.
1741
+
1742
+ **The fake writes nothing to standard error, ever.** If your test output has a
1743
+ line in it, it came from your code or from Effect's logger, not from here.
1744
+
1745
+ ### Injecting a read failure
1746
+
1747
+ `FakeScript.getObject` is a list of `FakeOutcome`s consumed one per `getObject`,
1748
+ for the retry and fallback paths a script of *objects* cannot express:
1749
+ `FakeOutcome.transportError("UNAVAILABLE")` to drive `SuiCore`'s read retry,
1750
+ `FakeOutcome.notFound()` for an `ObjectNotFound`, `FakeOutcome.timeoutThen` for
1751
+ an interruption. A `succeed` entry — and an absent or exhausted script — serves
1752
+ the object map as usual. `SuiTest.scriptGetObject(outcomes)` sets it mid-test.
1753
+
1754
+ `FakeScript.balances` is keyed by **owner and coin type**: an entry with an
1755
+ `owner` answers only for that address, one without answers for any (which is
1756
+ what a pre-0.1.2 script meant), and an owner with no entry gets zero, the way a
1757
+ node answers.
1758
+
1759
+ ### The fixture package's `node_modules` is not your source
1760
+
1761
+ An isolated-consumer fixture — a directory with its own `package.json` that
1762
+ installs a packed tarball, the way `scripts/check-package.ts` builds one — is
1763
+ typechecked against **whatever tarball it last installed** the moment `test` is
1764
+ in the package `tsconfig`'s `include`. So a fixture left over from a previous
1765
+ release quietly typechecks your new code against the old library, and the
1766
+ checklist item "put `test` in `include`" turns into a stale pin. Either exclude
1767
+ the fixture directory from `include` (it has its own `tsconfig`), or re-pack and
1768
+ re-install it as a step of `check`, with `bun install --force` when the filename
1769
+ did not change.
1770
+
1514
1771
  ### Your own fake beside the harness
1515
1772
 
1516
1773
  `layerExtensionTest` composes: the first argument is *your* layer, and your
@@ -1546,6 +1803,9 @@ the harness's.
1546
1803
 
1547
1804
  ### What the fake does and does not do
1548
1805
 
1806
+ - **`getObject` can be scripted to fail.** `FakeScript.getObject` /
1807
+ `SuiTest.scriptGetObject` inject a transport failure, a miss or a timeout into
1808
+ a read; the object map serves everything else.
1549
1809
  - **Its client supports `$extend`.** `SuiCoreFake`'s handle exposes `client`, a
1550
1810
  `ClientWithCoreApi` that implements `$extend`, so a derived Promise face can
1551
1811
  be tested exactly the way a consumer writes it — `fake.client.$extend(escrow(options))`
@@ -1610,7 +1870,19 @@ extension exits with the code a wrapper can act on — 5 applied, 4 not applied,
1610
1870
  `SuiError.toJson` serializes your errors too. A tag in @unconfirmed/sui-effect's own taxonomy
1611
1871
  encodes through the taxonomy's schema; **anything else that is a
1612
1872
  `Schema.TaggedError` encodes through its own**, so an extension error arrives as
1613
- `{ _tag, escrowId, outcome }` rather than a bare `{ _tag, message }`.
1873
+ `{ _tag, escrowId, outcome, message }` rather than a bare `{ _tag, message }`.
1874
+ `outcome` is there whichever form section 2 you chose — a class field is read
1875
+ off the instance and added, a `Schema.tag("not_applied")` field is simply
1876
+ encoded — but only the `Schema.tag` form survives a
1877
+ `Schema.decodeUnknownSync(YourError)` of that line back into an error, because
1878
+ only it is part of the schema.
1879
+
1880
+ `message` is there **only if your error has one.** `Schema.TaggedError` leaves
1881
+ `.message` empty, so an error that defines neither an `override get message()`
1882
+ nor a `message` schema field logs as an empty string and `toJson` emits no
1883
+ `message` key at all. Give every error one — the getter is the usual answer,
1884
+ because it stays out of the encoding and so costs nothing at the constructor,
1885
+ and `src/errors.ts` shows both forms.
1614
1886
 
1615
1887
  **`outcome` is in that JSON even though it is a class field.** Declaring it the
1616
1888
  way the template does — `readonly outcome: Outcome = "unknown"` beside the
@@ -1621,6 +1893,28 @@ field; there is nothing to change in your errors. That is
1621
1893
  what makes a structured log of a failed run useful, and it is a reason to give
1622
1894
  every field of an error a schema rather than stuffing detail into a string.
1623
1895
 
1896
+ **A wrapper error must carry what it wrapped.** `Script.exitCode` honours a
1897
+ declared `outcome` **before** the tag, which is what makes an extension's errors
1898
+ land on the right exit code — and what makes an error that wraps one and forgets
1899
+ to copy the `outcome` land on the wrong one. `catchAll(cause => new MyError({
1900
+ cause }))` around a `Tx.run` turns a charged `ExecutionFailed` (exit 5, do not
1901
+ retry) into an unclassified error (exit 1) or, worse, into a default
1902
+ `not_applied` (exit 4, "safe to retry") and the wrapper retries a transaction
1903
+ that already ran. Copy both fields: `outcome: SuiError.outcome(cause)` and the
1904
+ digest from `digestOf(cause)`, or do not wrap at all. The same applies to a CLI
1905
+ that catches at the command boundary and re-raises its own error type.
1906
+
1907
+ **A CLI with its own argv parser does not need `Script.run`.** `Script.run` is a
1908
+ whole entrypoint — it builds the layer, forks the root fiber, installs signal
1909
+ handlers and exits — and a commander program with twenty subcommands has all of
1910
+ that. What it still wants is the two things `run` does at the end:
1911
+ `Script.report(exit, { stderr?, journal? })` writes one diagnostic line per
1912
+ failure (with a `SubmissionUnknown`'s bytes) plus every unresolved journal
1913
+ entry, and returns the exit code. Assign it to `process.exitCode` rather than
1914
+ calling `process.exit`, so buffered output flushes, and pass the journal the
1915
+ program actually ran with — reading the default reference would look in the
1916
+ process-wide in-memory journal and find nothing.
1917
+
1624
1918
  Two of those deserve a second look. `UnexpectedEffects` — what
1625
1919
  `executed.expectCreated(type)` fails with — is **applied**, exit 5: it can only
1626
1920
  come from an `Executed`, so the transaction ran and gas was charged and only the
@@ -1642,6 +1936,18 @@ functions beside it is a different job, and the order that works is this.
1642
1936
  fine, the face maps it either way. Write the interface before you move any
1643
1937
  code: it is the only artefact the conversion is reviewed against.
1644
1938
 
1939
+ **Grep for the shapes the library replaces, not only for names.** Two are
1940
+ worth a pattern each. `\.find\(.*type\??\.includes\(` followed by a
1941
+ `throw` — "find the created object whose type contains `::Receipt`, or blow
1942
+ up" — is exactly `executed.expectCreated(type)`, which compares normalized
1943
+ struct tags and fails with `UnexpectedEffects { digest, expected, found }`.
1944
+ And `@<scope>/` imports resolved against the **published `exports` map**
1945
+ rather than against remembered call sites: regenerate the consumer-edit
1946
+ table with `grep -rn "@scope/" <consumer>/src` and one row per removed
1947
+ subpath export, because a re-export dropped from a subpath (`/party` no
1948
+ longer re-exporting `TxThunk`) breaks every importer while no facade call
1949
+ site changed at all.
1950
+
1645
1951
  **Grep for captured aliases, not only for dotted calls.** A consumer that
1646
1952
  writes `const party = client.miso.party` and then `party.join(...)` does not
1647
1953
  appear in a search for `client.miso.party.join`, and a namespace that looks
@@ -1687,13 +1993,13 @@ conversion is mechanical except where the behaviour deliberately changed.
1687
1993
  | `GraphQLUnavailableError` | `GraphQLUnavailable { method, reason }`, in the taxonomy, outcome `not_applied` — what `SuiGraphQL.layerUnavailable` rejects every call with |
1688
1994
  | `DeploymentError` | your own `<pkg>/DeploymentError` (the template's `EscrowUnsupportedNetwork`), a `Schema.TaggedError` declaring `outcome: "not_applied"`, failed from a `Layer.unwrap` that reads `sui.network` (section 6) |
1689
1995
  | `ObjectNotFoundError` | `ObjectNotFound`, plus `ObjectDeleted` and `ObjectUnavailable` from the SDK's own `reason` |
1690
- | `ObjectTypeMismatchError` | `DecodeError { objectId, expectedType, issue }` from the bridge's tag check |
1996
+ | `ObjectTypeMismatchError` | `DecodeError { objectId, expectedType, kind: "type", issue }` from the bridge's tag check. Branch on `kind`, which is `"type"` here and `"bytes"` for a BCS failure, so a consumer that used to catch a type mismatch to 404 a foreign object keeps doing exactly that and stops swallowing real decode bugs |
1691
1997
  | `SuiRpcError { operation }` | `TransportError { method }`. For your own HTTP or GraphQL calls, `TransportError.fromUnknown(method, cause, retryable?)` classifies the status and the retryability the way `SuiCore` does — do not hand-build the three fields |
1692
1998
  | `BcsDecodeError` | `DecodeError` |
1693
1999
  | `TransactionFailedError { digest, status }` | `ExecutionFailed { digest, reason, command, effects }` |
1694
2000
  | `getObjectContent` | `sui.getObject(id)` — with no schema, `content` is the raw bytes |
1695
2001
  | `getOptionalObjectContent` | `sui.getObjectOption` — `None` for missing and deleted, which is also the blessed way to express domain absence |
1696
- | `getObjectsContent` | `sui.getObjects` — chunked, integrity-checked, a per-item `Result` instead of silently dropping errored ids; `sui.getObjectsOrFail` when every id must be there |
2002
+ | `getObjectsContent` | `sui.getObjects` — chunked, integrity-checked, a per-item `Result` instead of silently dropping errored ids; `sui.getObjectsStrict` (the deprecated `getObjectsOrFail`) when every id must be there |
1697
2003
  | `listDynamicFields` | `sui.streamDynamicFields` |
1698
2004
  | filtering dynamic fields by key type | filter entries on `name.type` with `SuiSchema.matchesType` (never `normalizeStructTag`, which throws on the primitive key types), then decode `name.bcs` with `SuiSchema.decode(keyCodec, entry.name.bcs)`; the entry carries both |
1699
2005
  | `deriveDynamicFieldID` + `getObjectOption` for existence | `sui.getDynamicFieldOption(parent, name)` — one call, `None` for absent |
@@ -1707,12 +2013,21 @@ conversion is mechanical except where the behaviour deliberately changed.
1707
2013
  | `ParallelTransactionExecutor` | `Tx.run` per PTB, under the sender lock. Parallel submission from one address needs distinct gas owners (`Tx.sponsored`) and is otherwise deferred: the lock is what stops two transactions picking the same gas coin |
1708
2014
  | `ExecResult` and its extractors | `Executed` with `created(type)`, `createdWhere(predicate)`, `packagesPublished()`, `balanceChange(address, coinType)`, `expectCreated` |
1709
2015
  | a `register(client)` building a class of Promise methods | the service above plus `SuiExtension.fromService`, with `warm` when the surface has synchronous members |
2016
+ | a hand-rolled idempotent submitter (persist the signed bytes, execute, wait, re-poll by digest on error, resubmit the identical bytes) | `Tx.build` → `Tx.sign` → `Tx.submit` with a durable `Journal` (`@unconfirmed/sui-effect/journal`), and `Tx.reconcileAll()` at startup. The journal write before the first send, the verbatim resubmit and the reconcile are all in `Tx.submit`; what stays yours is the domain record, which goes in `Tx.run`'s `onSigned` hook |
2017
+ | `client.core.getTransaction(digest)` on a transaction that may have failed | `sui.core.getTransaction` — **not** `sui.getTransaction`, which fails with `ExecutionFailed` for a `FailedTransaction` (that is the point of it). Reach for the core tier when what you need is the failed transaction's own events or effects |
2018
+ | a `ready()` that checks the genesis digest before anything else | `Sui.layerNoDepsWith({ chainId: deployment.chainIdentifier })`. `Sui.layerNoDeps` asserts the **built-in table's** id for the client's network, which is not the same claim as "this is the chain my deployment manifest was generated against"; `layerNoDepsPinned(chainId)` asserts nothing and makes no call, for a consumer that has already checked |
2019
+ | a standalone read function the predecessor exported (`getReleaseById(client, id)`) | a member on the service taking the branded id and returning decoded content. Grep the consumer for the **function name**, not for a facade call site: a removed standalone export does not appear in any `client.*` search |
1710
2020
 
1711
2021
  Five behaviour changes to put in the conversion issues:
1712
2022
 
1713
2023
  1. `getObjects` returns a per-item `Result`; ids that failed are no longer
1714
- silently dropped. `getObjectsOrFail` is the fail-first variant.
1715
- 2. `balanceChange` and `gasUsedTotal` are `bigint`, not `number`.
2024
+ silently dropped. `getObjectsStrict` is the fail-first variant.
2025
+ 2. `balanceChange` and `gasUsedTotal` are `bigint`, not `number` — and a
2026
+ `bigint` **throws** in `JSON.stringify`. Anything that logs, persists or
2027
+ returns one over HTTP needs `value.toString()` or a replacer
2028
+ (`(_, v) => typeof v === "bigint" ? v.toString() : v`). Decimal strings are
2029
+ what the wire uses and what every schema here decodes from, so a string is
2030
+ the right thing to store.
1716
2031
  3. `created(type)` compares normalized struct tags; the substring matching of
1717
2032
  `createdByType` / `allCreatedByType` is `createdWhere(predicate)`.
1718
2033
  4. `Tx.run` replaces sign-and-execute plus wait, and a transport failure once
@@ -1779,7 +2094,13 @@ Reject an extension that:
1779
2094
  `SuiExtension.Leaf<T>` / `SuiExtension.leaf(value)`;
1780
2095
  - calls `.make` on a branded schema with a value that came from outside;
1781
2096
  - promises a `ConfigError` for an empty environment variable it reads with
1782
- `Config.option`.
2097
+ `Config.option`;
2098
+ - has a test double for a signer that is not a real SDK `Signer`
2099
+ (`Signer.fromSdkSigner` now rejects a value with no `toSuiAddress`,
2100
+ `getKeyScheme` or `signTransaction`; use `Signer.remote` for a double);
2101
+ - branches on a `DecodeError`'s `issue` text instead of its `kind`;
2102
+ - typechecks its tests against an isolated-consumer fixture's installed
2103
+ `node_modules` (see section 10) rather than against the source under test.
1783
2104
 
1784
2105
  The effect-ts skill's own checklist still applies underneath: v3 names,
1785
2106
  `Effect.gen` returned from a plain arrow, throwing inside an Effect, mutable
@@ -1922,7 +2243,250 @@ Put both halves of the swap on the release checklist:
1922
2243
  Say in the PR which form was used while the branch was in flight. A `link:` that
1923
2244
  reaches `main` is a build that works on one machine.
1924
2245
 
1925
- ## 17. What extension authors must know
2246
+ ## 17. Application consumers
2247
+
2248
+ The sections above are for the package that *is* an extension. This one is for
2249
+ the application that consumes one — a React or Svelte SPA, a Next route, a test
2250
+ suite on vitest — because none of its problems are the extension's and all of
2251
+ them are recurring.
2252
+
2253
+ **One runtime per process, at module scope.** An Effect program at the edge of a
2254
+ browser app wants exactly one `ManagedRuntime`, built once and imported
2255
+ everywhere:
2256
+
2257
+ <!-- inline -->
2258
+
2259
+ ```ts
2260
+ // sui-client.ts
2261
+ import { SuiGrpcClient } from "@mysten/sui/grpc"
2262
+ import { Sui, SuiCore } from "@unconfirmed/sui-effect"
2263
+ import { Effect, Layer, ManagedRuntime } from "effect"
2264
+
2265
+ const client = new SuiGrpcClient({ network: deployment.network, url: deployment.url })
2266
+
2267
+ // `layerNoDepsWith({ chainId })` over the client you already have: the chain id
2268
+ // asserted is the deployment's, not the built-in table's entry for the network.
2269
+ const layer = Sui.layerNoDepsWith({ chainId: deployment.chainIdentifier }).pipe(
2270
+ Layer.provide(SuiCore.layerFromClient(client))
2271
+ )
2272
+
2273
+ export const runtime = ManagedRuntime.make(layer)
2274
+ export const runSui = <A, E>(effect: Effect.Effect<A, E, Sui | SuiCore>): Promise<A> =>
2275
+ runtime.runPromise(effect)
2276
+
2277
+ // Vite / webpack HMR: dispose the old runtime, or every edit leaks one.
2278
+ if (import.meta.hot) import.meta.hot.dispose(() => void runtime.dispose())
2279
+ ```
2280
+
2281
+ **A `ManagedRuntime` memoizes its layer build, including a failure.** The build
2282
+ here makes one `getChainIdentifier` call, and if that call fails — a flaky
2283
+ network on the first paint, a proxy still waking up — the runtime caches the
2284
+ failed build and **every** later use fails with the same stale `TransportError`,
2285
+ forever. Three cures, and a browser app usually wants two of them: pass
2286
+ `retry` (`Sui.layerNoDepsWith({ chainId, retry: Schedule.exponential("200 millis") })`)
2287
+ so a transient failure does not decide the runtime's life; dispose and rebuild
2288
+ the runtime when a use fails at the layer (it is a module-level `let`, not a
2289
+ `const`, in that design); or use `Sui.layerNoDepsPinned(chainId)`, which makes no
2290
+ call at all when the deployment manifest has already told you the chain id.
2291
+
2292
+ **`Journal`'s default is process-wide memory, and in a browser that means
2293
+ nothing survives.** "A journal entry written before the first execute, so a
2294
+ crash mid-flight leaves a record" is true of a server process and false of a
2295
+ tab: a refresh is a new process with an empty `Map`, and two tabs are two
2296
+ journals and two sender locks. What an app actually has is the
2297
+ `SubmissionUnknown` in its hands — it carries the signed bytes, and
2298
+ `SuiError.describe` prints them — so persist *that* (IndexedDB, `localStorage`,
2299
+ your own backend) at the moment you catch it, and reconcile it on the next boot
2300
+ with `Tx.reconcile(signed)`. `@unconfirmed/sui-effect/journal` over a
2301
+ `KeyValueStore` is the durable version of the same idea when you want the
2302
+ library to do it; see the Workers section for the adapter shape.
2303
+
2304
+ **Mapping failures onto UI states.** `SuiError.outcome(error)` is the axis:
2305
+ `"applied"` means it happened and the UI must not offer "try again",
2306
+ `"unknown"` means show the digest and a reconcile action, `"not_applied"` means
2307
+ the button can be re-enabled. Two caveats. In a `catchAll` that only wraps a
2308
+ build, a simulate or a signature, pass `{ phase: "pre-submit" }`, or an
2309
+ extension error the taxonomy does not own comes back `"unknown"` and the UI
2310
+ offers a reconcile for a transaction that was never built. And
2311
+ `SuiError.describe(error)` is safe to show in a debug panel for **any** error,
2312
+ including one of your own — since 0.1.2 it falls back to the tag and message
2313
+ rather than returning nothing.
2314
+
2315
+ **Signing with an external cosigner, from an app.** A wallet signs as the
2316
+ sender, a sponsorship service signs as the gas owner and submits. That is not
2317
+ `Tx.run`: see "Sponsored by an external service" in section 5 for the exact
2318
+ sequence, and note that the digest to record is the one `Tx.sign` already
2319
+ returned.
2320
+
2321
+ **Testing an app on vitest.** The harness does not assume `bun:test`: it is
2322
+ `layerTest`/`layerExtensionTest` plus `SuiTest`, all ordinary Effect values.
2323
+ Give the app's own `runSui` a test double built on the same layer and the app's
2324
+ components are testable with no network at all:
2325
+
2326
+ <!-- inline -->
2327
+
2328
+ ```ts
2329
+ // test/sui-client.ts
2330
+ import { layerTest } from "@unconfirmed/sui-effect/testing"
2331
+ import { Effect, Layer, ManagedRuntime } from "effect"
2332
+ import { Journal } from "@unconfirmed/sui-effect/tx"
2333
+
2334
+ export const testRuntime = (script: Parameters<typeof layerTest>[0] = {}) => {
2335
+ const runtime = ManagedRuntime.make(
2336
+ Layer.mergeAll(layerTest(script), Journal.layerMemory)
2337
+ )
2338
+ return { runSui: runtime.runPromise.bind(runtime), dispose: () => runtime.dispose() }
2339
+ }
2340
+ ```
2341
+
2342
+ Dispose it in an `afterEach`, keep `Journal.layerMemory` in the layer (the
2343
+ default journal is process-wide and leaks entries between tests), and script
2344
+ `getTransaction: [FakeOutcome.notFound()]` on anything that submits unless the
2345
+ test means "this landed" — section 10 has the rest.
2346
+
2347
+ **Keep Effect out of the first paint if bundle size matters.** The runtime
2348
+ module above is a fine dynamic `import()`: nothing in it runs until something
2349
+ awaits it, and a lazy `$extend` registration costs nothing at module scope.
2350
+
2351
+ ## 18. Workers and Durable Objects
2352
+
2353
+ Cloudflare Workers, Durable Objects and every other isolate runtime work, with
2354
+ four differences that are not obvious.
2355
+
2356
+ **There is no `process`.** `Script` (and `Script.run`) is a Node entrypoint and
2357
+ does not belong here; build the layer directly. Configuration comes from the
2358
+ Worker's `env` argument, not from `process.env`, which means providing a
2359
+ `ConfigProvider` per request rather than relying on the default one:
2360
+
2361
+ <!-- inline -->
2362
+
2363
+ ```ts
2364
+ const provider = ConfigProvider.fromEnvRecord(env as Record<string, string>)
2365
+ const program = effect.pipe(Effect.provideService(ConfigProvider.ConfigProvider, provider))
2366
+ ```
2367
+
2368
+ **One runtime per isolate, never one per request.** `Effect.provide(effect,
2369
+ Sui.layerNoDeps)` inside a `fetch` handler rebuilds the layer — and its chain-id
2370
+ round trip — on every request. Cache a `ManagedRuntime` in module scope (a
2371
+ Worker isolate is reused across requests) or on the Durable Object instance, and
2372
+ read the "memoizes its failure" warning in section 17 (Application consumers): in an isolate that lives
2373
+ for hours, a cached failed build is a much longer outage than in a tab.
2374
+
2375
+ **The sender lock does not cross isolates.** `sui.withSenderLock` is a
2376
+ semaphore in one runtime's memory. Two isolates, two Durable Objects, two
2377
+ regions — two locks, and nothing stops both picking the same gas coin. Either
2378
+ serialize submissions for an address through one Durable Object (which is what
2379
+ DOs are for), or stop depending on the lock: with `tx.setGasPayment([])` there is
2380
+ no gas coin to equivocate on, the node picks from the address balance, and
2381
+ `SubmitConfig.lockSender: false` is then correct rather than reckless. A
2382
+ sponsored transaction built with `Tx.sponsored` already has an empty gas
2383
+ payment, and `Tx.build` preserves it through the resolver.
2384
+
2385
+ **Time is not wall-clock time in a DO alarm.** `Tx.submit`'s resubmit schedule
2386
+ and `visibilityTimeout` are Effect sleeps inside one invocation; a Durable
2387
+ Object that wants to retry across hours uses an alarm and calls
2388
+ `Tx.reconcileAll()` (or `Tx.reconcile(signed)`) when it wakes, with a durable
2389
+ `Journal` underneath. That is the split: sleeps for seconds, alarms plus the
2390
+ journal for anything longer.
2391
+
2392
+ ### A `KeyValueStore` over Durable Object storage
2393
+
2394
+ `@unconfirmed/sui-effect/journal` needs a `KeyValueStore`, and Effect ships
2395
+ memory, filesystem, SQL and Web Storage layers — none of which exist in a DO.
2396
+ `KeyValueStore.makeStringOnly({ get, set, remove, clear, size })` is the whole
2397
+ adapter: five members over strings, and the journal uses nothing else (its
2398
+ entries are JSON and its unresolved index is one more key).
2399
+
2400
+ <!-- inline -->
2401
+
2402
+ ```ts
2403
+ import { KeyValueStore } from "effect/unstable/persistence"
2404
+ import { Effect, Layer } from "effect"
2405
+ import { layerKeyValueStore } from "@unconfirmed/sui-effect/journal"
2406
+
2407
+ const durableStore = (storage: DurableObjectStorage) =>
2408
+ KeyValueStore.makeStringOnly({
2409
+ get: (key) =>
2410
+ Effect.map(
2411
+ Effect.promise(() => storage.get<string>(key)),
2412
+ Option.fromNullishOr
2413
+ ),
2414
+ set: (key, value) => Effect.promise(() => storage.put(key, value)),
2415
+ remove: (key) => Effect.asVoid(Effect.promise(() => storage.delete(key))),
2416
+ clear: Effect.promise(() => storage.deleteAll()),
2417
+ size: Effect.map(Effect.promise(() => storage.list()), (map) => map.size)
2418
+ })
2419
+
2420
+ const journal = (storage: DurableObjectStorage) =>
2421
+ layerKeyValueStore({ onUnresolved: "ignore" }).pipe(
2422
+ Layer.provide(Layer.succeed(KeyValueStore.KeyValueStore, durableStore(storage)))
2423
+ )
2424
+ ```
2425
+
2426
+ Build it once per DO instance, alongside the runtime. `onUnresolved: "fail"`
2427
+ refuses to build while the store still holds unsettled entries, which is the
2428
+ right setting for a process whose startup is allowed to demand attention and
2429
+ the wrong one for a DO that must answer the next request.
2430
+
2431
+ ## 19. Relay and sponsor envelopes
2432
+
2433
+ `Executed` describes the SDK's own execute include set: effects, events, balance
2434
+ changes and the `objectTypes` join. A relay, a sponsor or any service that
2435
+ submitted on your behalf sends back whatever *it* asked the node for, which is
2436
+ usually less — `changedObjects` with an `objectId` and an `idOperation` and
2437
+ nothing else, no `objectTypes`, no `balanceChanges`, no checkpoint, events as
2438
+ JSON with no BCS.
2439
+
2440
+ `Executed.fromPartial(envelope)` decodes exactly that. What it was not told
2441
+ stays "not told": the input and output states are `Unknown` rather than a
2442
+ guessed `ObjectWrite`, versions and digests are `null`, and the accessors read
2443
+ `Unknown` as "the envelope did not say" so `created()` and `deleted()` still
2444
+ classify from the id operation alone. JSON spellings are accepted where the
2445
+ SDK's types are not JSON — `bcs` as base64 or a byte array, every `u64` as a
2446
+ number or a `bigint` as well as the decimal string.
2447
+
2448
+ Two things it cannot invent:
2449
+
2450
+ - **the types.** `created(type)`, `mutated(type)` and `expectCreated(type)`
2451
+ match against the `objectTypes` join, so without one they match nothing. Ask
2452
+ your relay for `objectTypes`; failing that, use `created()` unfiltered or
2453
+ `createdWhere(predicate)` and read the ids.
2454
+ - **the gas.** `gasUsedTotal` is `0n` for an envelope that reported no gas.
2455
+ That means "not reported", not "free".
2456
+
2457
+ `Executed.fromTransactionResult(result)` is the other constructor: the strict
2458
+ one, for an SDK `TransactionResult` read with the full include set, which is
2459
+ what to use when the service handed you a real execute response.
2460
+
2461
+ ### `Tx.submitVia`: the journal, for a submission you do not make
2462
+
2463
+ `Tx.submit` is what writes journal entries, and a consumer that hands its bytes
2464
+ to a relay never calls it — so the crash window between "signed" and "the
2465
+ service answered" had no record at all. `Tx.submitVia` is that path:
2466
+
2467
+ <!-- inline -->
2468
+
2469
+ ```ts
2470
+ const executed = yield* Tx.submitVia(signed, (bytes, signatures) =>
2471
+ postToTheRelay({ bytes: toBase64(bytes), signature: signatures[0]! }))
2472
+ ```
2473
+
2474
+ It writes the `Signed` entry **before** calling `send`, calls `send` exactly
2475
+ once (a third party's submit is not known to be idempotent, and re-sending is
2476
+ not the library's decision), turns the reply into an `Executed` when it carries
2477
+ one — an SDK `TransactionResult`, a reduced envelope, or nothing at all, in
2478
+ which case it asks the chain by the digest it already has — and journals the
2479
+ terminal answer. A failure from `send` is ambiguous, so it ends in
2480
+ `Tx.reconcile` with the full evidence rules; an error whose instance declares
2481
+ `outcome: "not_applied"` is taken at its word and fails straight through
2482
+ without spending a reconcile, which is how a service says "I refused this and
2483
+ sent nothing". Declare that field on your relay-refusal error.
2484
+
2485
+ It fails with `ExecutionFailed`, `NotApplied`, `SubmissionUnknown` (carrying the
2486
+ bytes, with the sender's failure as its `cause`), `JournalError` from the write
2487
+ before the send, and your own error when it declared itself not-applied.
2488
+
2489
+ ## 20. What extension authors must know
1926
2490
 
1927
2491
  The short list an independent verification of v0.1.0 said a downstream
1928
2492
  conversion has to carry. Everything here is documented somewhere above; this is
@@ -1996,3 +2560,28 @@ the page to read before the conversion rather than after it.
1996
2560
  not, and never will, because it validates without decoding.
1997
2561
  - **`bun install --force` after re-packing a vendored tarball** with the same
1998
2562
  filename and version, or bun keeps the old extraction.
2563
+ - **`DecodeError` carries a `kind`** — `"type"`, `"bytes"`, `"shape"`. Branch on
2564
+ it, never on `issue`.
2565
+ - **Every taxonomy error has a real `.message`** since 0.1.2 (it is
2566
+ `SuiError.describe`), so anything that surfaces `.message` shows a line
2567
+ instead of an empty string, and `SuiError.describe` accepts a foreign error
2568
+ rather than returning `undefined` for it.
2569
+ - **`Tx.reconcileAll` returns a tagged union** — `{ _tag: "Executed", executed }`
2570
+ or `{ _tag, error }` — not a bare `Executed | error`. It returns **only what
2571
+ was unresolved**; `Tx.recorded(digest)` is how to ask about a settled one.
2572
+ - **`Tx.submit` fails outright on a gRPC `INVALID_ARGUMENT`** instead of
2573
+ reconciling: the node refused the request, nothing executed, and reconciling
2574
+ it would ask a question about a transaction that was never sent. That is the
2575
+ only `TransportError` that escapes `Tx.submit`.
2576
+ - **A sponsored submission needs both signatures before `Tx.submit`**, on the
2577
+ fake as on a node. `Tx.cosign`, or `Tx.run`'s `sponsor`.
2578
+ - **`Signer.fromSdkSigner` validates its argument** and reads `toSuiAddress()`
2579
+ and `getKeyScheme()` **at construction**. A test double needs all three
2580
+ members, or use `Signer.remote`.
2581
+ - **`Signer.fromConfig` takes a 32-byte hex seed** as well as a Bech32
2582
+ `suiprivkey`, defaulting to Ed25519.
2583
+ - **`Tx.run` has an `onSigned` hook** between the last signature and the first
2584
+ send, for a consumer's own record; `Tx.submitVia` is the same lifecycle when
2585
+ somebody else does the sending.
2586
+ - **`bigint` throws in `JSON.stringify`.** Gas, balances and versions are all
2587
+ `bigint`; use `.toString()` or a replacer at every JSON boundary.