@unconfirmed/sui-effect 0.1.0 → 0.1.2

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 (82) hide show
  1. package/AGENTS.md +47 -14
  2. package/CHANGELOG.md +86 -0
  3. package/LLMS.md +2889 -1726
  4. package/README.md +517 -11
  5. package/dist/domain/bcs.d.ts +44 -0
  6. package/dist/domain/bcs.d.ts.map +1 -1
  7. package/dist/domain/bcs.js +64 -0
  8. package/dist/domain/bcs.js.map +1 -1
  9. package/dist/domain/errors.d.ts +164 -26
  10. package/dist/domain/errors.d.ts.map +1 -1
  11. package/dist/domain/errors.js +193 -15
  12. package/dist/domain/errors.js.map +1 -1
  13. package/dist/domain/executed.d.ts +123 -18
  14. package/dist/domain/executed.d.ts.map +1 -1
  15. package/dist/domain/executed.js +196 -5
  16. package/dist/domain/executed.js.map +1 -1
  17. package/dist/domain/journal-entry.d.ts +6 -2
  18. package/dist/domain/journal-entry.d.ts.map +1 -1
  19. package/dist/domain/schemas.d.ts +234 -67
  20. package/dist/domain/schemas.d.ts.map +1 -1
  21. package/dist/domain/schemas.js +82 -4
  22. package/dist/domain/schemas.js.map +1 -1
  23. package/dist/domain/sui-schema.d.ts +3 -2
  24. package/dist/domain/sui-schema.d.ts.map +1 -1
  25. package/dist/domain/sui-schema.js +3 -2
  26. package/dist/domain/sui-schema.js.map +1 -1
  27. package/dist/extension.d.ts +1 -1
  28. package/dist/extension.d.ts.map +1 -1
  29. package/dist/extension.js +1 -1
  30. package/dist/extension.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/script.d.ts +1 -1
  36. package/dist/script.d.ts.map +1 -1
  37. package/dist/script.js +1 -1
  38. package/dist/script.js.map +1 -1
  39. package/dist/services/Script.d.ts +42 -0
  40. package/dist/services/Script.d.ts.map +1 -1
  41. package/dist/services/Script.js +69 -2
  42. package/dist/services/Script.js.map +1 -1
  43. package/dist/services/Signer.d.ts +32 -7
  44. package/dist/services/Signer.d.ts.map +1 -1
  45. package/dist/services/Signer.js +69 -10
  46. package/dist/services/Signer.js.map +1 -1
  47. package/dist/services/Sui.d.ts +42 -1
  48. package/dist/services/Sui.d.ts.map +1 -1
  49. package/dist/services/Sui.js +18 -4
  50. package/dist/services/Sui.js.map +1 -1
  51. package/dist/services/SuiCore.d.ts +12 -0
  52. package/dist/services/SuiCore.d.ts.map +1 -1
  53. package/dist/services/SuiCore.js +124 -0
  54. package/dist/services/SuiCore.js.map +1 -1
  55. package/dist/services/SuiCoreFake.d.ts +59 -4
  56. package/dist/services/SuiCoreFake.d.ts.map +1 -1
  57. package/dist/services/SuiCoreFake.js +199 -23
  58. package/dist/services/SuiCoreFake.js.map +1 -1
  59. package/dist/services/SuiExtension.d.ts +127 -14
  60. package/dist/services/SuiExtension.d.ts.map +1 -1
  61. package/dist/services/SuiExtension.js +125 -28
  62. package/dist/services/SuiExtension.js.map +1 -1
  63. package/dist/services/SuiGraphQL.d.ts +13 -0
  64. package/dist/services/SuiGraphQL.d.ts.map +1 -1
  65. package/dist/services/SuiGraphQL.js +13 -0
  66. package/dist/services/SuiGraphQL.js.map +1 -1
  67. package/dist/services/Tx.d.ts +630 -12
  68. package/dist/services/Tx.d.ts.map +1 -1
  69. package/dist/services/Tx.js +187 -8
  70. package/dist/services/Tx.js.map +1 -1
  71. package/dist/testing.d.ts +22 -3
  72. package/dist/testing.d.ts.map +1 -1
  73. package/dist/testing.js +39 -3
  74. package/dist/testing.js.map +1 -1
  75. package/dist/tx.d.ts +1 -1
  76. package/dist/tx.d.ts.map +1 -1
  77. package/dist/tx.js +1 -1
  78. package/dist/tx.js.map +1 -1
  79. package/docs/extensions.md +871 -33
  80. package/examples/extension-template/src/Escrow.ts +1 -1
  81. package/examples/extension-template/test/escrow.test.ts +91 -2
  82. package/package.json +3 -2
@@ -198,6 +198,36 @@ nothing happened — answering `"not_applied"` would tell the documented retry
198
198
  idiom to send again. The `"not_applied"` default is for @unconfirmed/sui-effect's own
199
199
  taxonomy, not for yours.
200
200
 
201
+ **Tag strings are namespaced by whoever defined them, and sui-effect's are
202
+ not.** @unconfirmed/sui-effect's own tags are bare — `TransportError`,
203
+ `ObjectNotFound`, `DecodeError` — while an extension prefixes its own, so a
204
+ platform's tag is `partyos/PartyNotFound` or `EscrowNotFound` depending on the
205
+ convention that package chose. `Effect.catchTag` matches the string exactly, so
206
+ **copy the tag from the installed package**, never from a migration note or
207
+ from memory: a `catchTag("PartyNotFound")` against a package that ships
208
+ `partyos/PartyNotFound` compiles (the union is open at the string level in
209
+ neither direction you expect) or silently never fires, and a README that
210
+ disagrees with the class is the single most common conversion bug. When you
211
+ rename or re-prefix a tag, that is a breaking change and belongs in your
212
+ changelog with the old and the new string side by side.
213
+
214
+ **`DecodeError` carries a `kind`, and that is what to branch on.** `"type"` is
215
+ "this object is not of the type I asked for" — the one a read service answers
216
+ with a 404 or a `filter`. `"bytes"` is "the type matched and the BCS did not
217
+ parse", which is a layout mismatch between your package and the chain and must
218
+ never be swallowed. `"shape"` is a domain schema refusing an already-parsed
219
+ value. The `issue` string is for a human and its wording changes between
220
+ releases; branching on it is how a foreign-object 404 quietly starts hiding a
221
+ real decode bug.
222
+
223
+ **`SuiError.outcome` takes a phase.** The default (`"post-submit"`) answers
224
+ `"unknown"` for a tag it does not recognise, because after a submission an
225
+ unfamiliar error is not evidence that nothing was sent. In a `catchAll` that can
226
+ only be reached **before** a submission — validation, a build, a signature —
227
+ pass `{ phase: "pre-submit" }` and an unrecognised tag becomes `"not_applied"`,
228
+ which is true by construction there. `SuiError.isTaxonomy(error)` is the same
229
+ question one level lower.
230
+
201
231
  Do not invent an error for something the taxonomy already names. A node that
202
232
  could not be reached is a `TransportError`; bytes that did not decode are a
203
233
  `DecodeError`; a transaction that aborted on chain is an `ExecutionFailed`; a
@@ -283,6 +313,28 @@ export const SettlementContent = (typeOrigin: string) =>
283
313
  )
284
314
  ```
285
315
 
316
+ When the mapping is a plain function that may throw — a constructor, a
317
+ `BigInt(...)`, a branding call — `SuiSchema.decodeWith(layout, type, map)` is
318
+ that composition in one call:
319
+
320
+ <!-- inline -->
321
+
322
+ ```ts
323
+ const EscrowContent = (typeOrigin: string) =>
324
+ SuiSchema.decodeWith(
325
+ EscrowLayout,
326
+ escrowType(typeOrigin),
327
+ (raw) => new Escrow(ObjectId.normalize(raw.id), BigInt(raw.amount))
328
+ )
329
+ ```
330
+
331
+ A throw inside `map` becomes the `DecodeError` the caller already handles,
332
+ carrying the expected type — which is the part that gets forgotten when the same
333
+ thing is written as `Effect.try` around `Schema.decodeUnknownEffect`, where the
334
+ throw arrives as a defect instead. The codec still carries the Move type, so
335
+ `sui.getObject(id, { schema })` checks the tag before it parses. There is no
336
+ encoder: a mapping function has no inverse, so serialize with the layout itself.
337
+
286
338
  The domain class is an ordinary `Schema.Class`:
287
339
 
288
340
  <!-- from: examples/extension-template/src/schema.ts -->
@@ -323,6 +375,39 @@ comparing `address::module::name` only. Give it a tag that *carries* type
323
375
  arguments and it is compared in full, after normalization, so
324
376
  `Coin<0x2::sui::SUI>` does not accept `Coin<…::usdc::USDC>`.
325
377
 
378
+ Worked, on owned objects, because that is where it pays: one codec and one bare
379
+ tag serve every instantiation a wallet holds, including on the fake.
380
+
381
+ <!-- inline -->
382
+
383
+ ```ts
384
+ // One codec for every `Composition<T>`, built from the type origin.
385
+ const compositionType = (typeOrigin: string) => `${typeOrigin}::composition::Composition`
386
+ const CompositionContent = (typeOrigin: string) =>
387
+ SuiSchema.bcs(CompositionLayout, compositionType(typeOrigin))
388
+
389
+ // Every composition an address owns, whatever it is parameterized by.
390
+ const owned = (owner: SuiAddress) =>
391
+ sui.streamOwnedObjects(owner, {
392
+ type: compositionType(typeOrigin),
393
+ schema: CompositionContent(typeOrigin)
394
+ })
395
+
396
+ // In a test, the fake filters with the same rule, so objects whose `type` is
397
+ // the instantiated tag are served for the bare one:
398
+ const script = {
399
+ objects: [
400
+ { objectId: FIRST, type: `${ORIGIN}::composition::Composition<${ORIGIN}::share::Share>`, … },
401
+ { objectId: SECOND, type: `${ORIGIN}::composition::Composition<0x2::sui::SUI>`, … }
402
+ ]
403
+ }
404
+ // `owned(address)` yields both, each decoded by the one codec.
405
+ ```
406
+
407
+ The instantiation is not lost: each object keeps its own tag on
408
+ `SuiObject.type`, so a member that cares which one it read still can. What the
409
+ bare tag does is stop you writing a codec per type argument.
410
+
326
411
  The same rule holds everywhere a Move type is compared: the `expectedType`
327
412
  option of `getObject` / `getObjectOption` / `getObjects`, `SuiSchema.decode`'s
328
413
  `actualType`, the `type` filter of `streamOwnedObjects`, and the fake's filter
@@ -434,8 +519,23 @@ becomes a typed `DecodeError`; `.make` on it is a defect in a member whose error
434
519
  union says it cannot fail.
435
520
 
436
521
  They also validate rather than normalize: `SuiAddress.make("0x1")` throws,
437
- because `0x1` is not a 32-byte address. Normalize first
438
- (`normalizeSuiAddress`) or write the padded form.
522
+ because `0x1` is not a 32-byte address. **`SuiAddress.normalize` and
523
+ `ObjectId.normalize` are the pair that take the shorthand**: they run the
524
+ schema's own decode (`normalizeSuiAddress`) and then brand, so `"0x1"`, an
525
+ unpadded hex string and the padded form all produce the same branded value.
526
+
527
+ <!-- inline -->
528
+
529
+ ```ts
530
+ const treasury = SuiAddress.normalize("0x2") // 0x0000…0002, branded
531
+ const clock = ObjectId.normalize("0x6")
532
+ ```
533
+
534
+ They throw, exactly like `.make`, so they are still for literals and
535
+ configuration **you** control — a deployment constant, a CLI flag you already
536
+ validated. Anything that arrived from a node, a user or an upstream package goes
537
+ through `Schema.decodeUnknownEffect(ObjectId)` and becomes a typed
538
+ `DecodeError`.
439
539
 
440
540
  When the error you would build *needs a field you do not have* — a `DecodeError`
441
541
  wants an `objectId` and you are decoding an event payload with no object — that
@@ -488,7 +588,8 @@ out of fifty is not a failed read. Two idioms, and you should pick deliberately:
488
588
  `results.filter(Result.isSuccess).map((result) => result.success)`, or
489
589
  `Result.getOrElse(result, () => fallback)` per item, or a `Map` keyed by id so
490
590
  a caller can ask about one;
491
- - **hard** — every id must be there: `sui.getObjectsOrFail(ids, opts)`, which
591
+ - **hard** — every id must be there: `sui.getObjectsStrict(ids, opts)` (named
592
+ `getObjectsOrFail` before 0.1.2, and still reachable under that name), which
492
593
  fails with the first item's error (`ObjectNotFound`, `ObjectDeleted`,
493
594
  `ObjectUnavailable` or `DecodeError`) and otherwise hands back the objects in
494
595
  the order of the ids.
@@ -648,6 +749,66 @@ An extension whose two parties cannot both sign in one process — the sponsor i
648
749
  a remote service, the sender is a wallet — uses the explicit lifecycle instead:
649
750
  `Tx.build`, `Tx.sign`, hand the bytes over, `Tx.cosign`, `Tx.submit`.
650
751
 
752
+ ### A signer double, for tests
753
+
754
+ `Signer.fromSdkSigner` reads `toSuiAddress()` and `getKeyScheme()` **at
755
+ construction** and rejects a value that has neither, so a partial double is a
756
+ `TypeError` where it used to be `scheme: undefined` and silence. A double is
757
+ `Signer.remote`, which takes exactly what a signer is:
758
+
759
+ <!-- inline -->
760
+
761
+ ```ts
762
+ const doubleSigner = (address: SuiAddress): Signer =>
763
+ Signer.remote({
764
+ address,
765
+ scheme: "ED25519",
766
+ signTransaction: () => Effect.succeed("AAAA…" as string) // any non-empty string decodes
767
+ })
768
+ ```
769
+
770
+ For a test that actually submits, use a real keypair
771
+ (`Signer.fromKeypair(Ed25519Keypair.fromSecretKey(new Uint8Array(32).fill(7)))`):
772
+ the fake checks that the signatures cover the addresses the bytes name, and a
773
+ fabricated signature only passes the count check.
774
+
775
+ ### Sponsored by an external service
776
+
777
+ `Tx.run`'s `sponsor` and `Tx.cosign` both want a local `Signer`. A service that
778
+ co-signs **and submits** on your behalf — a relayer, a sponsorship API — has
779
+ neither, and `Tx.submit` is not the last step either, because the service sends
780
+ the bytes. The supported sequence is:
781
+
782
+ <!-- inline -->
783
+
784
+ ```ts
785
+ const built = yield* Tx.build(Tx.sponsored({ sender, gasOwner })(recipe), {
786
+ sender,
787
+ gasOwner
788
+ })
789
+ const signed = yield* Tx.sign(built, signer)
790
+ // The wire form: base64 bytes, and the serialized signature string the SDK
791
+ // produces. `signed.signatures[0]` is the sender's; the service adds its own.
792
+ const envelope = {
793
+ transactionBlockBytes: toBase64(signed.bytes),
794
+ signature: signed.signatures[0]!
795
+ }
796
+ const reply = yield* callTheSponsor(envelope)
797
+ // Two ways to end, and both are supported:
798
+ // the service returned an execute envelope — decode it and keep the accessors
799
+ const executed = yield* Executed.fromPartial(reply)
800
+ // or it returned only a digest — ask the chain yourself
801
+ const settled = yield* Tx.reconcile(signed)
802
+ ```
803
+
804
+ Three things to know. The digest does not change when the sponsor adds its
805
+ signature, so `signed.digest` is the digest to record and to reconcile by.
806
+ `Tx.reconcile(signed)` — passing the `Signed`, not the bare digest — is what
807
+ gets the evidence rules, so a service that never sent the bytes ends as
808
+ `NotApplied` rather than as an eternal `SubmissionUnknown`. And whatever the
809
+ service returns is a **reduced** envelope: see "Relay and sponsor envelopes"
810
+ below, and use `Executed.fromPartial` rather than `Schema.decodeUnknownSync`.
811
+
651
812
  ## 6. Layers
652
813
 
653
814
  Following the house convention: `layer(opts)` for the live one, `layerConfig`
@@ -815,7 +976,8 @@ different extensions on the same client. After that:
815
976
  - an `Effect` member is a zero-argument method returning a `Promise`;
816
977
  - a function returning an `Effect` keeps its arguments and returns a `Promise`;
817
978
  - a `Stream` is an `AsyncIterable`, usable in `for await`;
818
- - a nested namespace is mapped recursively;
979
+ - a nested namespace is mapped recursively, **including one typed as an
980
+ `interface`** — the recursion is by type, not by how the member was declared;
819
981
  - a plain value passes through;
820
982
  - a rejection is **the same tagged error instance**, so a Promise consumer can
821
983
  still switch on `_tag` and read `outcome`;
@@ -860,6 +1022,15 @@ export const platform = (options: PlatformRegistrationOptions) =>
860
1022
  })
861
1023
  ```
862
1024
 
1025
+ **A `warm` registration runs the whole layer synchronously, so every failure of
1026
+ that layer is thrown out of `$extend`.** Not only an asynchronous step and not
1027
+ only a missing chain id: a deployment your bundle does not have for this
1028
+ network, a `ConfigError`, a `NetworkMismatch`, anything the layer declares.
1029
+ There is no first call to reject, because the layer is built before `register`
1030
+ returns. Catch it where you register, and say so in your registration's JSDoc.
1031
+ The mirror image is the lazy default, where the layer's failure surfaces as the
1032
+ rejection of whatever call needed it first.
1033
+
863
1034
  `warm` has two conditions and both are enforced. The layer must not perform an
864
1035
  asynchronous step — a layer that reads the network at build cannot be built
865
1036
  synchronously and `register` throws. And the chain identifier is **taken, not
@@ -876,6 +1047,35 @@ chain. What catches it is the chain itself: `Tx.build` stamps that id on the
876
1047
  transaction's expiration and a validator refuses bytes signed for another chain.
877
1048
  Register lazily when the assertion is what you want.
878
1049
 
1050
+ **Do not over-invest in testing the mismatch checks.** A consumer that reads
1051
+ both from one deployment manifest — `network: deployment.network` on the client
1052
+ and `chainId: deployment.chainIdentifier` on the registration — cannot make the
1053
+ network check and the chain-id check fire except by supplying an inconsistent
1054
+ pair on purpose. One test that a wrong `chainId` throws is worth having; a
1055
+ matrix over the combinations is testing the manifest, not the code.
1056
+
1057
+ **In a browser, `warm` throws into your import graph.** The natural place for a
1058
+ registration in an SPA is module scope, and a throw there takes down the whole
1059
+ module — no error boundary, no console line a user can act on, just a blank
1060
+ page. Two patterns work: register lazily and `await client.ext.$ready()` in a
1061
+ boot step that has somewhere to put the failure; or keep the `warm`
1062
+ registration and wrap it, exporting the error instead of throwing it:
1063
+
1064
+ <!-- inline -->
1065
+
1066
+ ```ts
1067
+ // sui-client.ts
1068
+ export let bootError: unknown
1069
+ export const client = (() => {
1070
+ try {
1071
+ return baseClient.$extend(escrow({ warm: { chainId } }))
1072
+ } catch (cause) {
1073
+ bootError = cause
1074
+ return baseClient.$extend(escrow({})) // lazy: every call rejects, nothing throws
1075
+ }
1076
+ })()
1077
+ ```
1078
+
879
1079
  **Thread the chain id through your registration options**, the way
880
1080
  `src/extension.ts` and `src/Platform.ts` both do, rather than relying on the
881
1081
  built-in table. It is what makes the face work on `devnet` and `localnet`, and
@@ -883,26 +1083,132 @@ it is what lets every registration on one client agree — see the next
883
1083
  paragraph. The template has a test for the warm face on a network with no
884
1084
  built-in chain id; a conversion should have one too.
885
1085
 
1086
+ **What a cold call actually is.** The value a member call returns before the
1087
+ runtime exists is a real `Promise` subclass that also implements
1088
+ `Symbol.asyncIterator`, because nothing yet knows whether the member was an
1089
+ `Effect` (a Promise) or a `Stream` (an `AsyncIterable`). So `instanceof Promise`
1090
+ holds, `for await` works, and in `bun:test`
1091
+ `await expect(client.ext.thing()).rejects.toBeInstanceOf(ExtensionNotReady)`
1092
+ does what it looks like. (In 0.1.0 it was a bare thenable and `.rejects` did not
1093
+ recognise it; `await ... .catch()` was the workaround and is no longer needed.)
1094
+
1095
+ Its rejection is also **pre-handled**: a cold call nobody awaits —
1096
+ `client.ext.doThing()` written as a statement — rejects with
1097
+ `ExtensionNotReady` into a no-op catch rather than aborting the process on an
1098
+ unhandled rejection. Your own `await` still throws. Write the test that proves
1099
+ this for your own face; it is the one failure mode that kills a test run rather
1100
+ than failing a test.
1101
+
1102
+ And **`$dispose()` keeps a warm registration warm**: the next use re-runs the
1103
+ same warm build rather than leaving every synchronous member throwing
1104
+ `ExtensionNotReady` forever after.
1105
+
886
1106
  If your extension's surface is entirely `Effect` and `Stream` members, none of
887
1107
  this applies: the lazy default is right and the first `await` builds everything.
888
1108
 
889
- ### Non-plain values are leaves, and plain ones are not
1109
+ ### Namespaces, leaves, and the one member that still lies
1110
+
1111
+ The face recurses into object-typed members — that is what makes
1112
+ `client.platform.escrow.get(id)` work — and **the recursion is by type, not by
1113
+ declaration style**. An `interface`-typed namespace (`readonly escrow:
1114
+ EscrowService`) is mapped exactly like an inline object literal. In 0.1.0 it was
1115
+ not: the type's bound was `Record<string, unknown>`, which an interface is not
1116
+ assignable to, so an interface-typed namespace kept its `Effect` members **in
1117
+ the type** while the runtime mapped them to Promises. If you carried a local
1118
+ type alias to work around that, delete it.
1119
+
1120
+ These are the leaves — passed through whole, in the type and at runtime alike:
1121
+ functions, arrays, `Uint8Array`, `Date`, `Promise`, and a BCS codec (anything
1122
+ with both `parse` and `serialize`, which is every `BcsType`). Exposing a codec
1123
+ as a member is safe.
1124
+
1125
+ For **any other class instance** — a `Schema.Class` instance, a policy object,
1126
+ anything with methods of its own — say so:
1127
+
1128
+ <!-- inline -->
1129
+
1130
+ ```ts
1131
+ import { SuiExtension } from "@unconfirmed/sui-effect/extension"
1132
+
1133
+ interface MyService {
1134
+ readonly policy: SuiExtension.Leaf<Policy>
1135
+ }
1136
+ // in the layer:
1137
+ return { policy: SuiExtension.leaf(new Policy()) }
1138
+ ```
1139
+
1140
+ `Leaf<T>` **is** a `T`, so the Effect face is unaffected; what it does is tell
1141
+ the Promise face that this member is a value rather than a namespace of members.
1142
+ Without it the type would recurse into the class while the runtime passes class
1143
+ instances through untouched, and for a class whose methods return `Effect`s that
1144
+ is the same lie in the other direction.
1145
+
1146
+ The remaining disagreement is the one that cannot be resolved by types at all. A
1147
+ **plain-object value** member — `deployment: { packageId }` — is
1148
+ indistinguishable from a namespace of members, so the type maps it as the value
1149
+ while the **cold** face treats it as a namespace and hands back a placeholder
1150
+ for `deployment.packageId`. Reading that placeholder throws `ExtensionNotReady`
1151
+ naming the path, so it is typed and named rather than silent, but it is still a
1152
+ disagreement. Either register `warm` (or `await $ready()`), or expose the value
1153
+ through an `Effect` member. Do not put a plain-object value member on a service
1154
+ that consumers will register lazily.
1155
+
1156
+ **Write a `PromiseFace<Service>` type test per namespace.** It is four lines, it
1157
+ is the only thing that catches a face type that has drifted from the runtime,
1158
+ and the template has one to copy:
1159
+
1160
+ <!-- from: examples/extension-template/test/escrow.test.ts -->
1161
+
1162
+ ```ts
1163
+ describe("the Promise face type", () => {
1164
+ /** Compile-time assignability, as a value a test can assert on. */
1165
+ const assignableTo = <_A extends _B, _B>(): true => true
1166
+
1167
+ type EscrowFace = PromiseFace<EscrowService>
1168
+ type PlatformFace = PromiseFace<PlatformService>
1169
+
1170
+ test("an Effect member becomes a Promise-returning method", () => {
1171
+ expect(assignableTo<EscrowFace["get"], (id: ObjectId) => Promise<EscrowObject>>()).toBe(true)
1172
+ expect(assignableTo<EscrowFace["feeCollector"], () => Promise<SuiAddress>>()).toBe(true)
1173
+ })
1174
+
1175
+ test("a Stream member becomes an AsyncIterable", () => {
1176
+ expect(
1177
+ assignableTo<EscrowFace["owned"]["stream"], (owner: SuiAddress) => AsyncIterable<EscrowObject>>()
1178
+ ).toBe(true)
1179
+ })
1180
+
1181
+ test("a synchronous member stays synchronous", () => {
1182
+ expect(assignableTo<EscrowFace["packageId"], string>()).toBe(true)
1183
+ expect(assignableTo<EscrowFace["claim"], (escrow: EscrowObject) => Recipe>()).toBe(true)
1184
+ })
890
1185
 
891
- The face maps plain object literals recursively and passes everything else
892
- through: a `BcsType`, a `Schema.Class` instance, a `Date` anything with a
893
- prototype of its own arrives whole, in the type and at runtime alike. So
894
- exposing a codec or a domain class as a member is safe, and a "namespace" must
895
- be a plain object literal to be mapped as one.
1186
+ test("an interface-typed namespace is mapped all the way down", () => {
1187
+ // `PlatformService.escrow` is `EscrowService`, an interface. The members
1188
+ // reached through it must be the mapped ones, not the Effect ones.
1189
+ expect(assignableTo<PlatformFace["escrow"]["get"], (id: ObjectId) => Promise<EscrowObject>>())
1190
+ .toBe(true)
1191
+ expect(
1192
+ assignableTo<
1193
+ PlatformFace["escrow"]["owned"]["count"],
1194
+ (owner: SuiAddress) => Promise<number>
1195
+ >()
1196
+ ).toBe(true)
1197
+ expect(assignableTo<PlatformFace["escrow"]["packageId"], string>()).toBe(true)
1198
+ })
896
1199
 
897
- The other half of that rule is the trap. A **plain-object value** member —
898
- `deployment: { packageId }` — is indistinguishable from a namespace of members,
899
- so the type maps it as the value while the **cold** face treats it as a
900
- namespace and hands back a placeholder for `deployment.packageId`. Reading that
901
- placeholder throws `ExtensionNotReady` naming the path, so the disagreement is
902
- typed and named rather than silent, but it is still a disagreement. Either
903
- register `warm` (or `await $ready()`), or expose the value through an `Effect`
904
- member, or give it a prototype of its own. Do not put a plain-object value
905
- member on a service that consumers will register lazily.
1200
+ test("the composition's own member is mapped too", () => {
1201
+ expect(
1202
+ assignableTo<
1203
+ PlatformFace["claimEverything"],
1204
+ (ids: ReadonlyArray<ObjectId>, opts: { readonly signer: Signer }) => Promise<
1205
+ ReadonlyArray<ChangedRef>
1206
+ >
1207
+ >()
1208
+ ).toBe(true)
1209
+ })
1210
+ })
1211
+ ```
906
1212
 
907
1213
  ### The rest of the contract
908
1214
 
@@ -1011,6 +1317,51 @@ The same shape holds for a dependency that is not an extension at all — a
1011
1317
  `SuiGraphQL` client, an `HttpClient`, your own operator service: yield it in
1012
1318
  `make`, provide its layer in `layer`.
1013
1319
 
1320
+ #### A standalone function that needs a sibling extension
1321
+
1322
+ A service member yields its dependencies; a **standalone exported function**
1323
+ has no layer of its own, and the temptation is to build the sibling's layer
1324
+ inside it on every call or, worse, to keep a module-level `ManagedRuntime`.
1325
+ Neither is sanctioned. There are exactly two shapes, and the first is the
1326
+ default:
1327
+
1328
+ <!-- inline -->
1329
+
1330
+ ```ts
1331
+ // 1. Take the sibling's service as a parameter. The caller already holds it —
1332
+ // it is in a member's `Effect.gen`, or in a script that provided the layer —
1333
+ // and the function stays `R = Sui`, testable with the sibling's test layer
1334
+ // and nothing else.
1335
+ export const settleAll = Effect.fn("settleAll")(function*(
1336
+ escrow: EscrowService,
1337
+ ids: ReadonlyArray<ObjectId>,
1338
+ opts: { readonly signer: Signer }
1339
+ ) {
1340
+ const settled: Array<ChangedRef> = []
1341
+ for (const id of ids) settled.push(yield* escrow.claimFor(id, opts))
1342
+ return settled
1343
+ })
1344
+
1345
+ // 2. Require it, and let the caller provide it once. Use this when the function
1346
+ // is part of a surface whose consumers already hold the layer.
1347
+ export const settleAllOwned = Effect.fn("settleAllOwned")(function*(
1348
+ owner: SuiAddress,
1349
+ opts: { readonly signer: Signer }
1350
+ ) {
1351
+ const escrow = yield* Escrow // R = Sui | Escrow
1352
+
1353
+ })
1354
+ ```
1355
+
1356
+ What not to do: `Layer.build` (or `Effect.provide(Escrow.layer(options))`)
1357
+ **inside** the function body. It is one layer build per call — a fresh cache, a
1358
+ fresh connection, a fresh sender lock for whatever the sibling holds — and the
1359
+ options have to come from somewhere, which is how a package id ends up read from
1360
+ `process.env` three files away from the service that owns it. If a function
1361
+ genuinely needs to build the sibling itself, build it **once** in the function
1362
+ that builds the registration and close over the service, the way `Platform.layer`
1363
+ does with `Layer.provide`.
1364
+
1014
1365
  ## 9. Wrapping an upstream Promise package
1015
1366
 
1016
1367
  For upstream SDKs we do not own (suins, deepbook, whatever comes next) we do not
@@ -1038,7 +1389,7 @@ const feeCollector = sui.core
1038
1389
  Effect.flatMap((raw) =>
1039
1390
  decodeAddress(raw).pipe(
1040
1391
  Effect.mapError((issue) =>
1041
- new DecodeError({ expectedType: "SuiAddress", issue: issue.message })
1392
+ new DecodeError({ expectedType: "SuiAddress", kind: "shape", issue: issue.message })
1042
1393
  )
1043
1394
  )
1044
1395
  ),
@@ -1083,9 +1434,12 @@ release.
1083
1434
 
1084
1435
  ### If your extension reads GraphQL
1085
1436
 
1086
- `SuiGraphQL` is a tag over the SDK's own client, not a wrapper, and
1087
- `SuiGraphQL.query(run, method?)` is the one call that sorts out the two
1088
- failures:
1437
+ `SuiGraphQL` is a tag over the SDK's own client, not a wrapper: `yield*
1438
+ SuiGraphQL` hands back the `SuiGraphQLClient` that was passed to
1439
+ `SuiGraphQL.layer(client)`, so the service type is `SuiGraphQL["Service"]`
1440
+ (which *is* `SuiGraphQLClient`) and that is what a helper taking it as a
1441
+ parameter should be typed with. `SuiGraphQL.query(run, method?)` is the one call
1442
+ that sorts out the two failures:
1089
1443
 
1090
1444
  <!-- inline -->
1091
1445
 
@@ -1139,6 +1493,19 @@ const provide = <A, E>(
1139
1493
  So a test exercises the production high tier: the include sets, the BCS bridge,
1140
1494
  the chunked batch reads, the sender lock and every `Tx` step.
1141
1495
 
1496
+ It also provides **`SuiGraphQL.layerUnavailable`**, so an extension that reads
1497
+ GraphQL builds in a test with no endpoint and every GraphQL call fails with
1498
+ `GraphQLUnavailable` — the failure it already handles. Anything else your layer
1499
+ requires that the client could not have given it goes in the third argument:
1500
+
1501
+ <!-- inline -->
1502
+
1503
+ ```ts
1504
+ const layer = layerExtensionTest(Escrow.layerTest(), script, {
1505
+ extra: SuiGraphQL.layer(scriptedClient) // or an HttpClient, an operator service…
1506
+ })
1507
+ ```
1508
+
1142
1509
  The `script` is what the fake serves — objects with real BCS content, gas coins,
1143
1510
  and scripted outcomes for simulate, execute and `getTransaction`:
1144
1511
 
@@ -1167,6 +1534,19 @@ const script = {
1167
1534
  }
1168
1535
  ```
1169
1536
 
1537
+ **`waitForTransaction` is scripted through `getTransaction`.** There is no
1538
+ separate knob: `Tx.submit` waits by polling `getTransaction`, so the ordered
1539
+ `FakeScript.getTransaction` outcomes (and `FakeScript.transactions` /
1540
+ `SuiTest.recordTransaction` for answers keyed by digest) are what decide whether
1541
+ a wait succeeds, times out or reports the transaction missing. A test that wants
1542
+ "executed, then not visible for two polls, then visible" scripts exactly that
1543
+ list.
1544
+
1545
+ `FakeScript.coinMetadata` is the same idea for `getCoinMetadata`: a record keyed
1546
+ by coin type, with an unscripted type answering `{ coinMetadata: null }` the way
1547
+ a node does. Unscripted entirely, the method dies naming itself, like every
1548
+ other method the script does not cover.
1549
+
1170
1550
  `SuiTest` drives the fake from inside an `Effect`: `putObject`, `bumpVersion`,
1171
1551
  `deleteObject`, `setClock` (the chain's clock, which is what `Tx.build` bounds a
1172
1552
  transaction against — Effect's `TestClock` drives the program's own time),
@@ -1251,6 +1631,91 @@ test("a retryable transport failure re-sends the identical bytes", async () => {
1251
1631
  })
1252
1632
  ```
1253
1633
 
1634
+ ### Scripting a submit, and the traps in it
1635
+
1636
+ Four things about the fake decide whether a submit test means anything.
1637
+
1638
+ **`Tx.submit` asks `getTransaction` when it cannot get a clean answer.** A
1639
+ retryable transport failure, a timeout, anything that leaves the outcome open
1640
+ ends in `Tx.reconcile`, and `reconcile`'s first move is a `getTransaction` for
1641
+ the digest. So a script whose `getTransaction` is a `succeed` is saying "this
1642
+ transaction is on chain" — and a submit test that did not mean that gets a
1643
+ **success** out of a path it was trying to prove fails. Unless the test is
1644
+ modelling a landed transaction, script `getTransaction: [FakeOutcome.notFound()]`
1645
+ and let the evidence rules run.
1646
+
1647
+ **A sponsored submit needs `Tx.cosign` first.** The fake refuses a submission
1648
+ whose signatures do not cover the addresses the bytes name — by count, and by
1649
+ the addresses recovered from the signatures themselves — the way a validator
1650
+ does, with a gRPC `INVALID_ARGUMENT`. `Tx.submit` reports that one outright as a
1651
+ `TransportError { retryable: false, status: "INVALID_ARGUMENT" }` rather than
1652
+ reconciling it, because the node refused the request and nothing was executed.
1653
+ So a sponsored-flow test builds with `Tx.sponsored`, signs with the sender,
1654
+ **`Tx.cosign`s with the sponsor**, and only then submits; `Tx.run(recipe, {
1655
+ signer, sponsor })` does all of that itself. Assert it, too — the signature
1656
+ count is on the recorded call:
1657
+
1658
+ <!-- inline -->
1659
+
1660
+ ```ts
1661
+ const sent = yield* SuiTest.calls("executeTransaction")
1662
+ expect(sent).toHaveLength(1)
1663
+ expect((sent[0]!.options as { signatures: ReadonlyArray<string> }).signatures)
1664
+ .toHaveLength(2)
1665
+ ```
1666
+
1667
+ **`FakeOutcome.failWith` takes either reason shape.** The SDK's
1668
+ `SuiClientTypes.ExecutionError` is the wire shape — a top-level `message` and an
1669
+ `abortCode` **string** — and sui-effect's decoded `ExecutionReason` has a
1670
+ `bigint` `abortCode` and no `message`. Both are accepted and the second is
1671
+ encoded for you; anything that is neither throws where the fixture is written,
1672
+ naming both shapes, instead of failing a decode several calls later on an
1673
+ unrelated method.
1674
+
1675
+ **Which script slot drives the build's simulate.** `Tx.build` always simulates,
1676
+ and on the fake that simulate is the **resolver's**: it is recorded (so
1677
+ `SuiTest.calls("simulateTransaction")` counts it) and it is answered by
1678
+ `buildSimulate` when that script has entries, and otherwise by the ordered
1679
+ `simulate` script. So `simulate: [FakeOutcome.failWith(...)]` makes `Tx.build`
1680
+ and `Tx.run` fail with `SimulationFailed` the way a node would, and
1681
+ `buildSimulate` is the slot to use when a test needs the build's simulate and
1682
+ an explicit `sui.simulate` to answer differently.
1683
+
1684
+ **`layerTest` asserts the built-in chain id for `mainnet` and `testnet`.**
1685
+ It is `Sui.layerNoDeps`, the production layer, so a script that says
1686
+ `network: "mainnet"` and a `chainId` of its own fails to build with
1687
+ `NetworkMismatch`. Use `network: "localnet"` (the default, which asserts
1688
+ nothing) in fixtures, or the real bundled identifier for the network you named.
1689
+
1690
+ **The fake writes nothing to standard error, ever.** If your test output has a
1691
+ line in it, it came from your code or from Effect's logger, not from here.
1692
+
1693
+ ### Injecting a read failure
1694
+
1695
+ `FakeScript.getObject` is a list of `FakeOutcome`s consumed one per `getObject`,
1696
+ for the retry and fallback paths a script of *objects* cannot express:
1697
+ `FakeOutcome.transportError("UNAVAILABLE")` to drive `SuiCore`'s read retry,
1698
+ `FakeOutcome.notFound()` for an `ObjectNotFound`, `FakeOutcome.timeoutThen` for
1699
+ an interruption. A `succeed` entry — and an absent or exhausted script — serves
1700
+ the object map as usual. `SuiTest.scriptGetObject(outcomes)` sets it mid-test.
1701
+
1702
+ `FakeScript.balances` is keyed by **owner and coin type**: an entry with an
1703
+ `owner` answers only for that address, one without answers for any (which is
1704
+ what a pre-0.1.2 script meant), and an owner with no entry gets zero, the way a
1705
+ node answers.
1706
+
1707
+ ### The fixture package's `node_modules` is not your source
1708
+
1709
+ An isolated-consumer fixture — a directory with its own `package.json` that
1710
+ installs a packed tarball, the way `scripts/check-package.ts` builds one — is
1711
+ typechecked against **whatever tarball it last installed** the moment `test` is
1712
+ in the package `tsconfig`'s `include`. So a fixture left over from a previous
1713
+ release quietly typechecks your new code against the old library, and the
1714
+ checklist item "put `test` in `include`" turns into a stale pin. Either exclude
1715
+ the fixture directory from `include` (it has its own `tsconfig`), or re-pack and
1716
+ re-install it as a step of `check`, with `bun install --force` when the filename
1717
+ did not change.
1718
+
1254
1719
  ### Your own fake beside the harness
1255
1720
 
1256
1721
  `layerExtensionTest` composes: the first argument is *your* layer, and your
@@ -1286,6 +1751,9 @@ the harness's.
1286
1751
 
1287
1752
  ### What the fake does and does not do
1288
1753
 
1754
+ - **`getObject` can be scripted to fail.** `FakeScript.getObject` /
1755
+ `SuiTest.scriptGetObject` inject a transport failure, a miss or a timeout into
1756
+ a read; the object map serves everything else.
1289
1757
  - **Its client supports `$extend`.** `SuiCoreFake`'s handle exposes `client`, a
1290
1758
  `ClientWithCoreApi` that implements `$extend`, so a derived Promise face can
1291
1759
  be tested exactly the way a consumer writes it — `fake.client.$extend(escrow(options))`
@@ -1350,10 +1818,39 @@ extension exits with the code a wrapper can act on — 5 applied, 4 not applied,
1350
1818
  `SuiError.toJson` serializes your errors too. A tag in @unconfirmed/sui-effect's own taxonomy
1351
1819
  encodes through the taxonomy's schema; **anything else that is a
1352
1820
  `Schema.TaggedError` encodes through its own**, so an extension error arrives as
1353
- `{ _tag, escrowId, outcome }` rather than a bare `{ _tag, message }`. That is
1821
+ `{ _tag, escrowId, outcome }` rather than a bare `{ _tag, message }`.
1822
+
1823
+ **`outcome` is in that JSON even though it is a class field.** Declaring it the
1824
+ way the template does — `readonly outcome: Outcome = "unknown"` beside the
1825
+ schema fields — keeps the call site clean, and a class field is not part of the
1826
+ schema, so encoding alone would drop exactly the field a wrapper script acts on.
1827
+ `toJson` reads it off the instance and puts it back. Keep declaring it as a
1828
+ field; there is nothing to change in your errors. That is
1354
1829
  what makes a structured log of a failed run useful, and it is a reason to give
1355
1830
  every field of an error a schema rather than stuffing detail into a string.
1356
1831
 
1832
+ **A wrapper error must carry what it wrapped.** `Script.exitCode` honours a
1833
+ declared `outcome` **before** the tag, which is what makes an extension's errors
1834
+ land on the right exit code — and what makes an error that wraps one and forgets
1835
+ to copy the `outcome` land on the wrong one. `catchAll(cause => new MyError({
1836
+ cause }))` around a `Tx.run` turns a charged `ExecutionFailed` (exit 5, do not
1837
+ retry) into an unclassified error (exit 1) or, worse, into a default
1838
+ `not_applied` (exit 4, "safe to retry") and the wrapper retries a transaction
1839
+ that already ran. Copy both fields: `outcome: SuiError.outcome(cause)` and the
1840
+ digest from `digestOf(cause)`, or do not wrap at all. The same applies to a CLI
1841
+ that catches at the command boundary and re-raises its own error type.
1842
+
1843
+ **A CLI with its own argv parser does not need `Script.run`.** `Script.run` is a
1844
+ whole entrypoint — it builds the layer, forks the root fiber, installs signal
1845
+ handlers and exits — and a commander program with twenty subcommands has all of
1846
+ that. What it still wants is the two things `run` does at the end:
1847
+ `Script.report(exit, { stderr?, journal? })` writes one diagnostic line per
1848
+ failure (with a `SubmissionUnknown`'s bytes) plus every unresolved journal
1849
+ entry, and returns the exit code. Assign it to `process.exitCode` rather than
1850
+ calling `process.exit`, so buffered output flushes, and pass the journal the
1851
+ program actually ran with — reading the default reference would look in the
1852
+ process-wide in-memory journal and find nothing.
1853
+
1357
1854
  Two of those deserve a second look. `UnexpectedEffects` — what
1358
1855
  `executed.expectCreated(type)` fails with — is **applied**, exit 5: it can only
1359
1856
  come from an `Executed`, so the transaction ran and gas was charged and only the
@@ -1371,9 +1868,27 @@ functions beside it is a different job, and the order that works is this.
1371
1868
 
1372
1869
  1. **Inventory the namespaces first.** List what consumers actually call,
1373
1870
  grouped the way they call it (`ids`, `tx`, `protocol`, `party`). That list is
1374
- your service interface, and a group is a plain object member on it. Write the
1375
- interface before you move any code: it is the only artefact the conversion is
1376
- reviewed against.
1871
+ your service interface, and a group is a member on it an `interface` is
1872
+ fine, the face maps it either way. Write the interface before you move any
1873
+ code: it is the only artefact the conversion is reviewed against.
1874
+
1875
+ **Grep for the shapes the library replaces, not only for names.** Two are
1876
+ worth a pattern each. `\.find\(.*type\??\.includes\(` followed by a
1877
+ `throw` — "find the created object whose type contains `::Receipt`, or blow
1878
+ up" — is exactly `executed.expectCreated(type)`, which compares normalized
1879
+ struct tags and fails with `UnexpectedEffects { digest, expected, found }`.
1880
+ And `@<scope>/` imports resolved against the **published `exports` map**
1881
+ rather than against remembered call sites: regenerate the consumer-edit
1882
+ table with `grep -rn "@scope/" <consumer>/src` and one row per removed
1883
+ subpath export, because a re-export dropped from a subpath (`/party` no
1884
+ longer re-exporting `TxThunk`) breaks every importer while no facade call
1885
+ site changed at all.
1886
+
1887
+ **Grep for captured aliases, not only for dotted calls.** A consumer that
1888
+ writes `const party = client.miso.party` and then `party.join(...)` does not
1889
+ appear in a search for `client.miso.party.join`, and a namespace that looks
1890
+ unused gets dropped from the interface. Search for the namespace name on its
1891
+ own (`\bclient\.\w+\.party\b`, `= .*\.party\b`) as well as for the calls.
1377
1892
  2. **Keep the standalone functions.** An existing `Effect<A, E, Sui>` function
1378
1893
  that is exported and used outside the facade stays exported and keeps its
1379
1894
  signature. Do not make consumers hold a service to call something that never
@@ -1414,13 +1929,13 @@ conversion is mechanical except where the behaviour deliberately changed.
1414
1929
  | `GraphQLUnavailableError` | `GraphQLUnavailable { method, reason }`, in the taxonomy, outcome `not_applied` — what `SuiGraphQL.layerUnavailable` rejects every call with |
1415
1930
  | `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) |
1416
1931
  | `ObjectNotFoundError` | `ObjectNotFound`, plus `ObjectDeleted` and `ObjectUnavailable` from the SDK's own `reason` |
1417
- | `ObjectTypeMismatchError` | `DecodeError { objectId, expectedType, issue }` from the bridge's tag check |
1932
+ | `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 |
1418
1933
  | `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 |
1419
1934
  | `BcsDecodeError` | `DecodeError` |
1420
1935
  | `TransactionFailedError { digest, status }` | `ExecutionFailed { digest, reason, command, effects }` |
1421
1936
  | `getObjectContent` | `sui.getObject(id)` — with no schema, `content` is the raw bytes |
1422
1937
  | `getOptionalObjectContent` | `sui.getObjectOption` — `None` for missing and deleted, which is also the blessed way to express domain absence |
1423
- | `getObjectsContent` | `sui.getObjects` — chunked, integrity-checked, a per-item `Result` instead of silently dropping errored ids; `sui.getObjectsOrFail` when every id must be there |
1938
+ | `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 |
1424
1939
  | `listDynamicFields` | `sui.streamDynamicFields` |
1425
1940
  | 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 |
1426
1941
  | `deriveDynamicFieldID` + `getObjectOption` for existence | `sui.getDynamicFieldOption(parent, name)` — one call, `None` for absent |
@@ -1434,12 +1949,21 @@ conversion is mechanical except where the behaviour deliberately changed.
1434
1949
  | `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 |
1435
1950
  | `ExecResult` and its extractors | `Executed` with `created(type)`, `createdWhere(predicate)`, `packagesPublished()`, `balanceChange(address, coinType)`, `expectCreated` |
1436
1951
  | a `register(client)` building a class of Promise methods | the service above plus `SuiExtension.fromService`, with `warm` when the surface has synchronous members |
1952
+ | 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 |
1953
+ | `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 |
1954
+ | 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 |
1955
+ | 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 |
1437
1956
 
1438
1957
  Five behaviour changes to put in the conversion issues:
1439
1958
 
1440
1959
  1. `getObjects` returns a per-item `Result`; ids that failed are no longer
1441
- silently dropped. `getObjectsOrFail` is the fail-first variant.
1442
- 2. `balanceChange` and `gasUsedTotal` are `bigint`, not `number`.
1960
+ silently dropped. `getObjectsStrict` is the fail-first variant.
1961
+ 2. `balanceChange` and `gasUsedTotal` are `bigint`, not `number` — and a
1962
+ `bigint` **throws** in `JSON.stringify`. Anything that logs, persists or
1963
+ returns one over HTTP needs `value.toString()` or a replacer
1964
+ (`(_, v) => typeof v === "bigint" ? v.toString() : v`). Decimal strings are
1965
+ what the wire uses and what every schema here decodes from, so a string is
1966
+ the right thing to store.
1443
1967
  3. `created(type)` compares normalized struct tags; the substring matching of
1444
1968
  `createdByType` / `allCreatedByType` is `createdWhere(predicate)`.
1445
1969
  4. `Tx.run` replaces sign-and-execute plus wait, and a transport failure once
@@ -1500,9 +2024,19 @@ Reject an extension that:
1500
2024
  carries, prefix included;
1501
2025
  - leaves `tests` out of the package `tsconfig`'s `include`, so its type-level
1502
2026
  pins never compile;
2027
+ - has no `PromiseFace<Service>` type test per namespace, or has one that only
2028
+ checks the top level;
2029
+ - exposes a class instance with `Effect`-returning methods without
2030
+ `SuiExtension.Leaf<T>` / `SuiExtension.leaf(value)`;
1503
2031
  - calls `.make` on a branded schema with a value that came from outside;
1504
2032
  - promises a `ConfigError` for an empty environment variable it reads with
1505
- `Config.option`.
2033
+ `Config.option`;
2034
+ - has a test double for a signer that is not a real SDK `Signer`
2035
+ (`Signer.fromSdkSigner` now rejects a value with no `toSuiAddress`,
2036
+ `getKeyScheme` or `signTransaction`; use `Signer.remote` for a double);
2037
+ - branches on a `DecodeError`'s `issue` text instead of its `kind`;
2038
+ - typechecks its tests against an isolated-consumer fixture's installed
2039
+ `node_modules` (see section 10) rather than against the source under test.
1506
2040
 
1507
2041
  The effect-ts skill's own checklist still applies underneath: v3 names,
1508
2042
  `Effect.gen` returned from a plain arrow, throwing inside an Effect, mutable
@@ -1616,6 +2150,19 @@ old one) before installing: a file that stopped shipping is caught there rather
1616
2150
  than in a consumer. Record the sui-effect commit or tag the vendor copy came
1617
2151
  from.
1618
2152
 
2153
+ **And re-install with `--force`.** bun keys a file dependency by name and
2154
+ version, not by content, so re-packing over `vendor/unconfirmed-sui-effect-0.1.1.tgz`
2155
+ and running `bun install` again leaves the *old* extraction in `node_modules` —
2156
+ silently, and for as long as it takes you to notice that a fix you just made is
2157
+ not there. Either bump the filename (`…-0.1.1+2.tgz`) or:
2158
+
2159
+ ```bash
2160
+ bun install --force
2161
+ ```
2162
+
2163
+ Verify it took: `cat node_modules/@unconfirmed/sui-effect/package.json | grep version`, or
2164
+ grep the shipped `dist` for the change you are looking for.
2165
+
1619
2166
  **Until the first publish, bun probes the registry for every peer.** It does so
1620
2167
  even for a peer a local dependency already satisfies, and an unpublished name
1621
2168
  404s the install. The escape is
@@ -1632,7 +2179,250 @@ Put both halves of the swap on the release checklist:
1632
2179
  Say in the PR which form was used while the branch was in flight. A `link:` that
1633
2180
  reaches `main` is a build that works on one machine.
1634
2181
 
1635
- ## 17. What extension authors must know
2182
+ ## 17. Application consumers
2183
+
2184
+ The sections above are for the package that *is* an extension. This one is for
2185
+ the application that consumes one — a React or Svelte SPA, a Next route, a test
2186
+ suite on vitest — because none of its problems are the extension's and all of
2187
+ them are recurring.
2188
+
2189
+ **One runtime per process, at module scope.** An Effect program at the edge of a
2190
+ browser app wants exactly one `ManagedRuntime`, built once and imported
2191
+ everywhere:
2192
+
2193
+ <!-- inline -->
2194
+
2195
+ ```ts
2196
+ // sui-client.ts
2197
+ import { SuiGrpcClient } from "@mysten/sui/grpc"
2198
+ import { Sui, SuiCore } from "@unconfirmed/sui-effect"
2199
+ import { Effect, Layer, ManagedRuntime } from "effect"
2200
+
2201
+ const client = new SuiGrpcClient({ network: deployment.network, url: deployment.url })
2202
+
2203
+ // `layerNoDepsWith({ chainId })` over the client you already have: the chain id
2204
+ // asserted is the deployment's, not the built-in table's entry for the network.
2205
+ const layer = Sui.layerNoDepsWith({ chainId: deployment.chainIdentifier }).pipe(
2206
+ Layer.provide(SuiCore.layerFromClient(client))
2207
+ )
2208
+
2209
+ export const runtime = ManagedRuntime.make(layer)
2210
+ export const runSui = <A, E>(effect: Effect.Effect<A, E, Sui | SuiCore>): Promise<A> =>
2211
+ runtime.runPromise(effect)
2212
+
2213
+ // Vite / webpack HMR: dispose the old runtime, or every edit leaks one.
2214
+ if (import.meta.hot) import.meta.hot.dispose(() => void runtime.dispose())
2215
+ ```
2216
+
2217
+ **A `ManagedRuntime` memoizes its layer build, including a failure.** The build
2218
+ here makes one `getChainIdentifier` call, and if that call fails — a flaky
2219
+ network on the first paint, a proxy still waking up — the runtime caches the
2220
+ failed build and **every** later use fails with the same stale `TransportError`,
2221
+ forever. Three cures, and a browser app usually wants two of them: pass
2222
+ `retry` (`Sui.layerNoDepsWith({ chainId, retry: Schedule.exponential("200 millis") })`)
2223
+ so a transient failure does not decide the runtime's life; dispose and rebuild
2224
+ the runtime when a use fails at the layer (it is a module-level `let`, not a
2225
+ `const`, in that design); or use `Sui.layerNoDepsPinned(chainId)`, which makes no
2226
+ call at all when the deployment manifest has already told you the chain id.
2227
+
2228
+ **`Journal`'s default is process-wide memory, and in a browser that means
2229
+ nothing survives.** "A journal entry written before the first execute, so a
2230
+ crash mid-flight leaves a record" is true of a server process and false of a
2231
+ tab: a refresh is a new process with an empty `Map`, and two tabs are two
2232
+ journals and two sender locks. What an app actually has is the
2233
+ `SubmissionUnknown` in its hands — it carries the signed bytes, and
2234
+ `SuiError.describe` prints them — so persist *that* (IndexedDB, `localStorage`,
2235
+ your own backend) at the moment you catch it, and reconcile it on the next boot
2236
+ with `Tx.reconcile(signed)`. `@unconfirmed/sui-effect/journal` over a
2237
+ `KeyValueStore` is the durable version of the same idea when you want the
2238
+ library to do it; see the Workers section for the adapter shape.
2239
+
2240
+ **Mapping failures onto UI states.** `SuiError.outcome(error)` is the axis:
2241
+ `"applied"` means it happened and the UI must not offer "try again",
2242
+ `"unknown"` means show the digest and a reconcile action, `"not_applied"` means
2243
+ the button can be re-enabled. Two caveats. In a `catchAll` that only wraps a
2244
+ build, a simulate or a signature, pass `{ phase: "pre-submit" }`, or an
2245
+ extension error the taxonomy does not own comes back `"unknown"` and the UI
2246
+ offers a reconcile for a transaction that was never built. And
2247
+ `SuiError.describe(error)` is safe to show in a debug panel for **any** error,
2248
+ including one of your own — since 0.1.2 it falls back to the tag and message
2249
+ rather than returning nothing.
2250
+
2251
+ **Signing with an external cosigner, from an app.** A wallet signs as the
2252
+ sender, a sponsorship service signs as the gas owner and submits. That is not
2253
+ `Tx.run`: see "Sponsored by an external service" in section 5 for the exact
2254
+ sequence, and note that the digest to record is the one `Tx.sign` already
2255
+ returned.
2256
+
2257
+ **Testing an app on vitest.** The harness does not assume `bun:test`: it is
2258
+ `layerTest`/`layerExtensionTest` plus `SuiTest`, all ordinary Effect values.
2259
+ Give the app's own `runSui` a test double built on the same layer and the app's
2260
+ components are testable with no network at all:
2261
+
2262
+ <!-- inline -->
2263
+
2264
+ ```ts
2265
+ // test/sui-client.ts
2266
+ import { layerTest } from "@unconfirmed/sui-effect/testing"
2267
+ import { Effect, Layer, ManagedRuntime } from "effect"
2268
+ import { Journal } from "@unconfirmed/sui-effect/tx"
2269
+
2270
+ export const testRuntime = (script: Parameters<typeof layerTest>[0] = {}) => {
2271
+ const runtime = ManagedRuntime.make(
2272
+ Layer.mergeAll(layerTest(script), Journal.layerMemory)
2273
+ )
2274
+ return { runSui: runtime.runPromise.bind(runtime), dispose: () => runtime.dispose() }
2275
+ }
2276
+ ```
2277
+
2278
+ Dispose it in an `afterEach`, keep `Journal.layerMemory` in the layer (the
2279
+ default journal is process-wide and leaks entries between tests), and script
2280
+ `getTransaction: [FakeOutcome.notFound()]` on anything that submits unless the
2281
+ test means "this landed" — section 10 has the rest.
2282
+
2283
+ **Keep Effect out of the first paint if bundle size matters.** The runtime
2284
+ module above is a fine dynamic `import()`: nothing in it runs until something
2285
+ awaits it, and a lazy `$extend` registration costs nothing at module scope.
2286
+
2287
+ ## 18. Workers and Durable Objects
2288
+
2289
+ Cloudflare Workers, Durable Objects and every other isolate runtime work, with
2290
+ four differences that are not obvious.
2291
+
2292
+ **There is no `process`.** `Script` (and `Script.run`) is a Node entrypoint and
2293
+ does not belong here; build the layer directly. Configuration comes from the
2294
+ Worker's `env` argument, not from `process.env`, which means providing a
2295
+ `ConfigProvider` per request rather than relying on the default one:
2296
+
2297
+ <!-- inline -->
2298
+
2299
+ ```ts
2300
+ const provider = ConfigProvider.fromEnvRecord(env as Record<string, string>)
2301
+ const program = effect.pipe(Effect.provideService(ConfigProvider.ConfigProvider, provider))
2302
+ ```
2303
+
2304
+ **One runtime per isolate, never one per request.** `Effect.provide(effect,
2305
+ Sui.layerNoDeps)` inside a `fetch` handler rebuilds the layer — and its chain-id
2306
+ round trip — on every request. Cache a `ManagedRuntime` in module scope (a
2307
+ Worker isolate is reused across requests) or on the Durable Object instance, and
2308
+ read the "memoizes its failure" warning in section 17 (Application consumers): in an isolate that lives
2309
+ for hours, a cached failed build is a much longer outage than in a tab.
2310
+
2311
+ **The sender lock does not cross isolates.** `sui.withSenderLock` is a
2312
+ semaphore in one runtime's memory. Two isolates, two Durable Objects, two
2313
+ regions — two locks, and nothing stops both picking the same gas coin. Either
2314
+ serialize submissions for an address through one Durable Object (which is what
2315
+ DOs are for), or stop depending on the lock: with `tx.setGasPayment([])` there is
2316
+ no gas coin to equivocate on, the node picks from the address balance, and
2317
+ `SubmitConfig.lockSender: false` is then correct rather than reckless. A
2318
+ sponsored transaction built with `Tx.sponsored` already has an empty gas
2319
+ payment, and `Tx.build` preserves it through the resolver.
2320
+
2321
+ **Time is not wall-clock time in a DO alarm.** `Tx.submit`'s resubmit schedule
2322
+ and `visibilityTimeout` are Effect sleeps inside one invocation; a Durable
2323
+ Object that wants to retry across hours uses an alarm and calls
2324
+ `Tx.reconcileAll()` (or `Tx.reconcile(signed)`) when it wakes, with a durable
2325
+ `Journal` underneath. That is the split: sleeps for seconds, alarms plus the
2326
+ journal for anything longer.
2327
+
2328
+ ### A `KeyValueStore` over Durable Object storage
2329
+
2330
+ `@unconfirmed/sui-effect/journal` needs a `KeyValueStore`, and Effect ships
2331
+ memory, filesystem, SQL and Web Storage layers — none of which exist in a DO.
2332
+ `KeyValueStore.makeStringOnly({ get, set, remove, clear, size })` is the whole
2333
+ adapter: five members over strings, and the journal uses nothing else (its
2334
+ entries are JSON and its unresolved index is one more key).
2335
+
2336
+ <!-- inline -->
2337
+
2338
+ ```ts
2339
+ import { KeyValueStore } from "effect/unstable/persistence"
2340
+ import { Effect, Layer } from "effect"
2341
+ import { layerKeyValueStore } from "@unconfirmed/sui-effect/journal"
2342
+
2343
+ const durableStore = (storage: DurableObjectStorage) =>
2344
+ KeyValueStore.makeStringOnly({
2345
+ get: (key) =>
2346
+ Effect.map(
2347
+ Effect.promise(() => storage.get<string>(key)),
2348
+ Option.fromNullishOr
2349
+ ),
2350
+ set: (key, value) => Effect.promise(() => storage.put(key, value)),
2351
+ remove: (key) => Effect.asVoid(Effect.promise(() => storage.delete(key))),
2352
+ clear: Effect.promise(() => storage.deleteAll()),
2353
+ size: Effect.map(Effect.promise(() => storage.list()), (map) => map.size)
2354
+ })
2355
+
2356
+ const journal = (storage: DurableObjectStorage) =>
2357
+ layerKeyValueStore({ onUnresolved: "ignore" }).pipe(
2358
+ Layer.provide(Layer.succeed(KeyValueStore.KeyValueStore, durableStore(storage)))
2359
+ )
2360
+ ```
2361
+
2362
+ Build it once per DO instance, alongside the runtime. `onUnresolved: "fail"`
2363
+ refuses to build while the store still holds unsettled entries, which is the
2364
+ right setting for a process whose startup is allowed to demand attention and
2365
+ the wrong one for a DO that must answer the next request.
2366
+
2367
+ ## 19. Relay and sponsor envelopes
2368
+
2369
+ `Executed` describes the SDK's own execute include set: effects, events, balance
2370
+ changes and the `objectTypes` join. A relay, a sponsor or any service that
2371
+ submitted on your behalf sends back whatever *it* asked the node for, which is
2372
+ usually less — `changedObjects` with an `objectId` and an `idOperation` and
2373
+ nothing else, no `objectTypes`, no `balanceChanges`, no checkpoint, events as
2374
+ JSON with no BCS.
2375
+
2376
+ `Executed.fromPartial(envelope)` decodes exactly that. What it was not told
2377
+ stays "not told": the input and output states are `Unknown` rather than a
2378
+ guessed `ObjectWrite`, versions and digests are `null`, and the accessors read
2379
+ `Unknown` as "the envelope did not say" so `created()` and `deleted()` still
2380
+ classify from the id operation alone. JSON spellings are accepted where the
2381
+ SDK's types are not JSON — `bcs` as base64 or a byte array, every `u64` as a
2382
+ number or a `bigint` as well as the decimal string.
2383
+
2384
+ Two things it cannot invent:
2385
+
2386
+ - **the types.** `created(type)`, `mutated(type)` and `expectCreated(type)`
2387
+ match against the `objectTypes` join, so without one they match nothing. Ask
2388
+ your relay for `objectTypes`; failing that, use `created()` unfiltered or
2389
+ `createdWhere(predicate)` and read the ids.
2390
+ - **the gas.** `gasUsedTotal` is `0n` for an envelope that reported no gas.
2391
+ That means "not reported", not "free".
2392
+
2393
+ `Executed.fromTransactionResult(result)` is the other constructor: the strict
2394
+ one, for an SDK `TransactionResult` read with the full include set, which is
2395
+ what to use when the service handed you a real execute response.
2396
+
2397
+ ### `Tx.submitVia`: the journal, for a submission you do not make
2398
+
2399
+ `Tx.submit` is what writes journal entries, and a consumer that hands its bytes
2400
+ to a relay never calls it — so the crash window between "signed" and "the
2401
+ service answered" had no record at all. `Tx.submitVia` is that path:
2402
+
2403
+ <!-- inline -->
2404
+
2405
+ ```ts
2406
+ const executed = yield* Tx.submitVia(signed, (bytes, signatures) =>
2407
+ postToTheRelay({ bytes: toBase64(bytes), signature: signatures[0]! }))
2408
+ ```
2409
+
2410
+ It writes the `Signed` entry **before** calling `send`, calls `send` exactly
2411
+ once (a third party's submit is not known to be idempotent, and re-sending is
2412
+ not the library's decision), turns the reply into an `Executed` when it carries
2413
+ one — an SDK `TransactionResult`, a reduced envelope, or nothing at all, in
2414
+ which case it asks the chain by the digest it already has — and journals the
2415
+ terminal answer. A failure from `send` is ambiguous, so it ends in
2416
+ `Tx.reconcile` with the full evidence rules; an error whose instance declares
2417
+ `outcome: "not_applied"` is taken at its word and fails straight through
2418
+ without spending a reconcile, which is how a service says "I refused this and
2419
+ sent nothing". Declare that field on your relay-refusal error.
2420
+
2421
+ It fails with `ExecutionFailed`, `NotApplied`, `SubmissionUnknown` (carrying the
2422
+ bytes, with the sender's failure as its `cause`), `JournalError` from the write
2423
+ before the send, and your own error when it declared itself not-applied.
2424
+
2425
+ ## 20. What extension authors must know
1636
2426
 
1637
2427
  The short list an independent verification of v0.1.0 said a downstream
1638
2428
  conversion has to carry. Everything here is documented somewhere above; this is
@@ -1683,3 +2473,51 @@ the page to read before the conversion rather than after it.
1683
2473
  `ConfigProvider` service.
1684
2474
  - **`SuiError.describe` covers `GraphQLUnavailable` and `ExtensionNotReady`**,
1685
2475
  and `Script.run` prints them like any other tag.
2476
+ - **Write a `PromiseFace<Service>` type test per namespace**, and put `test` in
2477
+ the package `tsconfig`'s `include` so those pins actually compile. A namespace
2478
+ may be an `interface`; since 0.1.1 the face maps it the same as a type alias,
2479
+ so the local aliases a 0.1.0 conversion carried for this are unnecessary.
2480
+ - **A class instance with `Effect`-returning methods needs
2481
+ `SuiExtension.leaf`.** `Uint8Array`, `Date`, `Promise`, arrays and BCS codecs
2482
+ are leaves already; everything else with a prototype of its own is passed
2483
+ through by the runtime and must say so in the type.
2484
+ - **A cold call is a real `Promise`** that is also an `AsyncIterable`, and its
2485
+ rejection is pre-handled, so `expect(...).rejects` works and an un-awaited
2486
+ cold call cannot abort the test run.
2487
+ - **`outcome` survives `SuiError.toJson`** even as a class field; keep declaring
2488
+ it as one.
2489
+ - **`warm` throws *any* layer failure synchronously out of `$extend`**, not only
2490
+ an asynchronous step or a missing chain id.
2491
+ - **Two copies of `@mysten/sui` are still a bug**, but `mapSdkError` duck-types
2492
+ the SDK's error classes now, so `ObjectNotFound` survives it and one warning
2493
+ names the real problem. Fix the duplication anyway: BCS codecs and
2494
+ `Transaction` inputs have no such fallback.
2495
+ - **`SuiAddress.normalize` / `ObjectId.normalize`** take `"0x1"`; `.make` does
2496
+ not, and never will, because it validates without decoding.
2497
+ - **`bun install --force` after re-packing a vendored tarball** with the same
2498
+ filename and version, or bun keeps the old extraction.
2499
+ - **`DecodeError` carries a `kind`** — `"type"`, `"bytes"`, `"shape"`. Branch on
2500
+ it, never on `issue`.
2501
+ - **Every taxonomy error has a real `.message`** since 0.1.2 (it is
2502
+ `SuiError.describe`), so anything that surfaces `.message` shows a line
2503
+ instead of an empty string, and `SuiError.describe` accepts a foreign error
2504
+ rather than returning `undefined` for it.
2505
+ - **`Tx.reconcileAll` returns a tagged union** — `{ _tag: "Executed", executed }`
2506
+ or `{ _tag, error }` — not a bare `Executed | error`. It returns **only what
2507
+ was unresolved**; `Tx.recorded(digest)` is how to ask about a settled one.
2508
+ - **`Tx.submit` fails outright on a gRPC `INVALID_ARGUMENT`** instead of
2509
+ reconciling: the node refused the request, nothing executed, and reconciling
2510
+ it would ask a question about a transaction that was never sent. That is the
2511
+ only `TransportError` that escapes `Tx.submit`.
2512
+ - **A sponsored submission needs both signatures before `Tx.submit`**, on the
2513
+ fake as on a node. `Tx.cosign`, or `Tx.run`'s `sponsor`.
2514
+ - **`Signer.fromSdkSigner` validates its argument** and reads `toSuiAddress()`
2515
+ and `getKeyScheme()` **at construction**. A test double needs all three
2516
+ members, or use `Signer.remote`.
2517
+ - **`Signer.fromConfig` takes a 32-byte hex seed** as well as a Bech32
2518
+ `suiprivkey`, defaulting to Ed25519.
2519
+ - **`Tx.run` has an `onSigned` hook** between the last signature and the first
2520
+ send, for a consumer's own record; `Tx.submitVia` is the same lifecycle when
2521
+ somebody else does the sending.
2522
+ - **`bigint` throws in `JSON.stringify`.** Gas, balances and versions are all
2523
+ `bigint`; use `.toString()` or a replacer at every JSON boundary.