@unconfirmed/sui-effect 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/AGENTS.md +209 -0
  2. package/LICENSE +21 -0
  3. package/LLMS.md +6707 -0
  4. package/README.md +300 -0
  5. package/dist/domain/bcs.d.ts +101 -0
  6. package/dist/domain/bcs.d.ts.map +1 -0
  7. package/dist/domain/bcs.js +183 -0
  8. package/dist/domain/bcs.js.map +1 -0
  9. package/dist/domain/errors.d.ts +595 -0
  10. package/dist/domain/errors.d.ts.map +1 -0
  11. package/dist/domain/errors.js +476 -0
  12. package/dist/domain/errors.js.map +1 -0
  13. package/dist/domain/executed.d.ts +325 -0
  14. package/dist/domain/executed.d.ts.map +1 -0
  15. package/dist/domain/executed.js +253 -0
  16. package/dist/domain/executed.js.map +1 -0
  17. package/dist/domain/journal-entry.d.ts +468 -0
  18. package/dist/domain/journal-entry.d.ts.map +1 -0
  19. package/dist/domain/journal-entry.js +63 -0
  20. package/dist/domain/journal-entry.js.map +1 -0
  21. package/dist/domain/schemas.d.ts +950 -0
  22. package/dist/domain/schemas.d.ts.map +1 -0
  23. package/dist/domain/schemas.js +571 -0
  24. package/dist/domain/schemas.js.map +1 -0
  25. package/dist/domain/sui-schema.d.ts +12 -0
  26. package/dist/domain/sui-schema.d.ts.map +1 -0
  27. package/dist/domain/sui-schema.js +12 -0
  28. package/dist/domain/sui-schema.js.map +1 -0
  29. package/dist/extension.d.ts +7 -0
  30. package/dist/extension.d.ts.map +1 -0
  31. package/dist/extension.js +7 -0
  32. package/dist/extension.js.map +1 -0
  33. package/dist/index.d.ts +31 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +31 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/internal.d.ts +18 -0
  38. package/dist/internal.d.ts.map +1 -0
  39. package/dist/internal.js +18 -0
  40. package/dist/internal.js.map +1 -0
  41. package/dist/journal.d.ts +51 -0
  42. package/dist/journal.d.ts.map +1 -0
  43. package/dist/journal.js +18 -0
  44. package/dist/journal.js.map +1 -0
  45. package/dist/script.d.ts +7 -0
  46. package/dist/script.d.ts.map +1 -0
  47. package/dist/script.js +7 -0
  48. package/dist/script.js.map +1 -0
  49. package/dist/services/Journal.d.ts +76 -0
  50. package/dist/services/Journal.d.ts.map +1 -0
  51. package/dist/services/Journal.js +70 -0
  52. package/dist/services/Journal.js.map +1 -0
  53. package/dist/services/JournalKeyValueStore.d.ts +73 -0
  54. package/dist/services/JournalKeyValueStore.d.ts.map +1 -0
  55. package/dist/services/JournalKeyValueStore.js +155 -0
  56. package/dist/services/JournalKeyValueStore.js.map +1 -0
  57. package/dist/services/Script.d.ts +210 -0
  58. package/dist/services/Script.d.ts.map +1 -0
  59. package/dist/services/Script.js +441 -0
  60. package/dist/services/Script.js.map +1 -0
  61. package/dist/services/Signer.d.ts +132 -0
  62. package/dist/services/Signer.d.ts.map +1 -0
  63. package/dist/services/Signer.js +158 -0
  64. package/dist/services/Signer.js.map +1 -0
  65. package/dist/services/SubmitConfig.d.ts +175 -0
  66. package/dist/services/SubmitConfig.d.ts.map +1 -0
  67. package/dist/services/SubmitConfig.js +52 -0
  68. package/dist/services/SubmitConfig.js.map +1 -0
  69. package/dist/services/Sui.d.ts +295 -0
  70. package/dist/services/Sui.d.ts.map +1 -0
  71. package/dist/services/Sui.js +414 -0
  72. package/dist/services/Sui.js.map +1 -0
  73. package/dist/services/SuiCore.d.ts +227 -0
  74. package/dist/services/SuiCore.d.ts.map +1 -0
  75. package/dist/services/SuiCore.js +400 -0
  76. package/dist/services/SuiCore.js.map +1 -0
  77. package/dist/services/SuiCoreFake.d.ts +278 -0
  78. package/dist/services/SuiCoreFake.d.ts.map +1 -0
  79. package/dist/services/SuiCoreFake.js +1003 -0
  80. package/dist/services/SuiCoreFake.js.map +1 -0
  81. package/dist/services/SuiExtension.d.ts +208 -0
  82. package/dist/services/SuiExtension.d.ts.map +1 -0
  83. package/dist/services/SuiExtension.js +355 -0
  84. package/dist/services/SuiExtension.js.map +1 -0
  85. package/dist/services/SuiGraphQL.d.ts +98 -0
  86. package/dist/services/SuiGraphQL.d.ts.map +1 -0
  87. package/dist/services/SuiGraphQL.js +118 -0
  88. package/dist/services/SuiGraphQL.js.map +1 -0
  89. package/dist/services/Tx.d.ts +687 -0
  90. package/dist/services/Tx.d.ts.map +1 -0
  91. package/dist/services/Tx.js +1224 -0
  92. package/dist/services/Tx.js.map +1 -0
  93. package/dist/testing.d.ts +88 -0
  94. package/dist/testing.d.ts.map +1 -0
  95. package/dist/testing.js +163 -0
  96. package/dist/testing.js.map +1 -0
  97. package/dist/tx.d.ts +24 -0
  98. package/dist/tx.d.ts.map +1 -0
  99. package/dist/tx.js +24 -0
  100. package/dist/tx.js.map +1 -0
  101. package/docs/extensions.md +1685 -0
  102. package/examples/extension-template/README.md +170 -0
  103. package/examples/extension-template/package.json +44 -0
  104. package/examples/extension-template/scripts/check-package.ts +147 -0
  105. package/examples/extension-template/src/Escrow.ts +445 -0
  106. package/examples/extension-template/src/Platform.ts +136 -0
  107. package/examples/extension-template/src/errors.ts +61 -0
  108. package/examples/extension-template/src/extension.ts +63 -0
  109. package/examples/extension-template/src/index.ts +34 -0
  110. package/examples/extension-template/src/schema.ts +158 -0
  111. package/examples/extension-template/src/upstream.ts +66 -0
  112. package/examples/extension-template/test/escrow.test.ts +559 -0
  113. package/examples/extension-template/tsconfig.build.json +26 -0
  114. package/examples/extension-template/tsconfig.json +35 -0
  115. package/package.json +87 -0
@@ -0,0 +1,1685 @@
1
+ # Writing a @unconfirmed/sui-effect extension
2
+
3
+ Every downstream SDK we own is an **extension**: an Effect service built on
4
+ `Sui` and `Tx`, published as its own package, with a derived Promise face for
5
+ consumers who have an SDK client and no Effect. The extension mechanism is how
6
+ most consumers reach Sui, so it is a contract rather than an escape hatch, and
7
+ this guide is that contract.
8
+
9
+ Read it with `examples/extension-template/` open. The template is a complete,
10
+ typechecked, tested package, and **every code block below is copied verbatim
11
+ from it** — `test/extensions-guide.test.ts` fails if the two drift. Each block
12
+ names the file it came from.
13
+
14
+ ## The shape in one paragraph
15
+
16
+ An extension is one `Context.Service` whose layer requires `Sui` and nothing it
17
+ could have built itself; whose every member returns an `Effect` with a closed
18
+ error union of @unconfirmed/sui-effect's taxonomy plus its own `Schema.TaggedError` classes;
19
+ whose contributions to a transaction are recipe fragments a consumer composes;
20
+ whose writes go through `Tx`, so the journal, the expiration, the sender lock
21
+ and reconcile apply to every transaction on the platform; whose credentials are
22
+ its own and whose signers are parameters; and whose Promise face is derived from
23
+ the service by `SuiExtension.fromService` rather than maintained beside it.
24
+
25
+ ## 1. The service
26
+
27
+ The interface is the contract. Write it first, before any implementation: it is
28
+ what consumers, agents and `LLMS.md` read.
29
+
30
+ <!-- from: examples/extension-template/src/Escrow.ts -->
31
+
32
+ ```ts
33
+ export interface EscrowService {
34
+ /** The package this service calls into. */
35
+ readonly packageId: string
36
+ /**
37
+ * The package the types this service decodes were **first** published in,
38
+ * which is what appears inside every Move type name. It is the same as
39
+ * `packageId` until the package is upgraded.
40
+ */
41
+ readonly typeOrigin: string
42
+ /**
43
+ * The address the package collects fees at, read through the upstream SDK.
44
+ *
45
+ * Fails with: `DecodeError` (the upstream answer was not an address),
46
+ * `TransportError`.
47
+ */
48
+ readonly feeCollector: Effect.Effect<SuiAddress, DecodeError | TransportError>
49
+ /**
50
+ * Reads one escrow object and decodes its content.
51
+ *
52
+ * Fails with: `EscrowNotFound` (no such object, or it was deleted),
53
+ * `DecodeError` (it is not an escrow), `TransportError`.
54
+ */
55
+ readonly get: (id: ObjectId) => Effect.Effect<
56
+ EscrowObject,
57
+ EscrowNotFound | DecodeError | TransportError
58
+ >
59
+ /**
60
+ * The commands that claim one escrow, as a recipe fragment.
61
+ *
62
+ * This is the composable half of the package: a consumer appends it to a
63
+ * transaction that also carries other extensions' fragments and submits once.
64
+ * Never fails; a recipe is synchronous, and `Tx.build` reports a recipe that
65
+ * throws as a `BuildError`.
66
+ */
67
+ readonly claim: (escrow: EscrowObject) => Recipe
68
+ /**
69
+ * Claims one escrow on the signer's behalf and tells the operator about it.
70
+ *
71
+ * The signer is a parameter, never a layer field. The write goes through
72
+ * `Tx.run`, so the journal, the expiration, the sender lock and reconcile all
73
+ * apply.
74
+ *
75
+ * Fails with: `EscrowNotFound`, `DecodeError`, `TransportError`,
76
+ * `BuildError`, `SimulationFailed`, `PolicyDenied`, `SigningError`,
77
+ * `ExecutionFailed`, `NotApplied`, `SubmissionUnknown`, `JournalError`,
78
+ * `UnexpectedEffects` (the claim applied but produced no receipt), and
79
+ * `EscrowSettlementUnknown` when the claim is on chain but the operator never
80
+ * confirmed it.
81
+ */
82
+ readonly claimFor: (
83
+ id: ObjectId,
84
+ opts: { readonly signer: Signer }
85
+ ) => Effect.Effect<ChangedRef, ClaimForError>
86
+ /** A namespace, which the Promise face maps recursively. */
87
+ readonly owned: {
88
+ /**
89
+ * Every escrow an address owns, paginated.
90
+ *
91
+ * Fails with: `DecodeError`, `TransportError`.
92
+ */
93
+ readonly stream: (
94
+ owner: SuiAddress
95
+ ) => Stream.Stream<EscrowObject, DecodeError | TransportError>
96
+ /**
97
+ * How many escrows an address owns.
98
+ *
99
+ * Fails with: `DecodeError`, `TransportError`.
100
+ */
101
+ readonly count: (
102
+ owner: SuiAddress
103
+ ) => Effect.Effect<number, DecodeError | TransportError>
104
+ }
105
+ }
106
+ ```
107
+
108
+ What to notice:
109
+
110
+ - **No `Promise` anywhere**, and no `unknown` in an error channel. A member is
111
+ an `Effect`, a function returning an `Effect`, a `Stream`, a plain value, or a
112
+ nested object of those. Nothing else.
113
+ - **Every member states its error union in words** in its JSDoc ("Fails
114
+ with: …"), the convention @unconfirmed/sui-effect itself follows, so the generated
115
+ documentation and a reading agent agree with the compiler.
116
+ - **A nested namespace is a plain object.** Platform surfaces in the wild group
117
+ dozens of members this way (`client.miso.protocol.*`); the Promise face maps
118
+ them recursively, so group freely.
119
+ - **Long unions get a name.** `RunError` is the union `Tx.run` declares.
120
+
121
+ <!-- from: examples/extension-template/src/Escrow.ts -->
122
+
123
+ ```ts
124
+ export type ClaimForError =
125
+ | EscrowNotFound
126
+ | EscrowSettlementUnknown
127
+ | DecodeError
128
+ | UnexpectedEffects
129
+ | RunError
130
+ ```
131
+
132
+ ### Identifiers and naming
133
+
134
+ The service identifier is `"<package>/<Name>"`. It is the runtime key every copy
135
+ of the module agrees on, so **it never changes after publication**. One service
136
+ per package unless there is a real reason for more — a composition over two
137
+ packages (section 8) is one.
138
+
139
+ A scoped package keeps its scope: `@misofm/partyos` names its service
140
+ `"@misofm/partyos/Partyos"`, not `"partyos/Partyos"`. The identifier is not a
141
+ JavaScript identifier and nothing parses it; what it has to be is unique, and
142
+ the published package name is the one string that already is.
143
+
144
+ <!-- from: examples/extension-template/src/Escrow.ts -->
145
+
146
+ ```ts
147
+ export class Escrow extends Context.Service<Escrow, EscrowService>()(
148
+ "example-extension/Escrow"
149
+ ) {
150
+ ```
151
+
152
+ Mirror the names of the thing you wrap, the way @unconfirmed/sui-effect mirrors the SDK: a
153
+ consumer who knows the Move package should be able to guess your method names.
154
+ The property the extension takes on a client (`client.escrow`) is the `name` in
155
+ the registration, and it is part of your API too.
156
+
157
+ ## 2. Errors and outcomes
158
+
159
+ Every failure is a `Schema.TaggedError` with fields a caller can act on, never a
160
+ bare message. Prefix the tag with the package name where a collision is
161
+ plausible — `EscrowNotFound` is a name two packages could both want.
162
+
163
+ <!-- from: examples/extension-template/src/errors.ts -->
164
+
165
+ ```ts
166
+ export class EscrowNotFound extends Schema.TaggedError<EscrowNotFound>()(
167
+ "escrow/EscrowNotFound",
168
+ { escrowId: ObjectId }
169
+ ) {
170
+ readonly outcome: Outcome = "not_applied"
171
+ }
172
+ ```
173
+
174
+ <!-- from: examples/extension-template/src/errors.ts -->
175
+
176
+ ```ts
177
+ export class EscrowSettlementUnknown extends Schema.TaggedError<EscrowSettlementUnknown>()(
178
+ "escrow/EscrowSettlementUnknown",
179
+ { escrowId: ObjectId, digest: Digest, message: Schema.String }
180
+ ) {
181
+ readonly outcome: Outcome = "unknown"
182
+ }
183
+ ```
184
+
185
+ `outcome` is the axis a wrapper script acts on: `"applied"` (it is on chain, gas
186
+ was charged, do not retry), `"unknown"` (reconcile before doing anything else),
187
+ `"not_applied"` (nothing happened, safe to retry). `SuiError.outcome` reads the
188
+ field off any error that declares one, and `Script.exitCode` maps the three to
189
+ exit 5, 3 and 4.
190
+
191
+ **Declare `outcome` on every error you define.** An extension error that does
192
+ not declare one is *unclassified*, and the two helpers answer differently on
193
+ purpose. `Script.exitCode` exits 1, the code that also means "defect", because
194
+ exit 3 would tell a wrapper there is a digest to reconcile and an unrecognised
195
+ error is not evidence that anything was ever sent. `SuiError.outcome` answers
196
+ `"unknown"`, because a tag it has never heard of is equally not evidence that
197
+ nothing happened — answering `"not_applied"` would tell the documented retry
198
+ idiom to send again. The `"not_applied"` default is for @unconfirmed/sui-effect's own
199
+ taxonomy, not for yours.
200
+
201
+ Do not invent an error for something the taxonomy already names. A node that
202
+ could not be reached is a `TransportError`; bytes that did not decode are a
203
+ `DecodeError`; a transaction that aborted on chain is an `ExecutionFailed`; a
204
+ transaction that applied but did not produce what you expected is an
205
+ `UnexpectedEffects`. Your own errors are for your own domain — policy, protocol
206
+ state, an operator service — and upstream failures are mapped into one or the
207
+ other.
208
+
209
+ **Never map an error onto one with a different outcome.** This is the mistake
210
+ worth naming: `UnexpectedEffects` says the transaction applied and gas was
211
+ charged, `TransportError` says nothing happened and a retry is safe. Mapping the
212
+ first onto the second tells a wrapper script to run the transaction again, and
213
+ the wrapper will. The same goes the other way: a `DecodeError` is a boundary
214
+ that was wrong, not a node that was unreachable, and dressing it as a
215
+ `TransportError` both loses the type that was expected and makes a declared
216
+ `DecodeError` unreachable. Map an error onto another only when the two say the
217
+ same thing about the chain.
218
+
219
+ ## 3. Reads through `Sui`, writes through `Tx`
220
+
221
+ <!-- from: examples/extension-template/src/Escrow.ts -->
222
+
223
+ ```ts
224
+ const get = Effect.fn("Escrow.get")(function*(id: ObjectId) {
225
+ return yield* sui.getObject(id, { schema: content }).pipe(
226
+ Effect.catchTag(
227
+ ["ObjectNotFound", "ObjectDeleted"],
228
+ () => Effect.fail(new EscrowNotFound({ escrowId: id }))
229
+ ),
230
+ Effect.catchTag(
231
+ "ObjectUnavailable",
232
+ (error) => Effect.fail(transport("escrow.get")(error))
233
+ )
234
+ )
235
+ })
236
+ ```
237
+
238
+ `sui.getObject(id, { schema })` checks the object's Move type against the type
239
+ recorded on the codec before it parses a byte, so a wrong object is a
240
+ `DecodeError` naming both types rather than a confusing parse failure. Notice
241
+ which failures are translated and which are not: a missing or deleted escrow is
242
+ this package's `EscrowNotFound`, but `ObjectUnavailable` — the node could not
243
+ say what happened to it — is a transport problem and stays one.
244
+
245
+ ### What the bridge takes, and where domain mapping goes
246
+
247
+ `SuiSchema.bcs(layout, type?)` takes a **`BcsType`** — a `@mysten/bcs` layout.
248
+ Generated `@mysten/codegen` output qualifies because its `MoveStruct`,
249
+ `MoveEnum` and `MoveTuple` extend `BcsStruct`, `BcsEnum` and `BcsTuple`, which
250
+ are `BcsType`s. A hand-rolled `{ parse(bytes) { … } }` does **not** qualify, and
251
+ that is deliberate: the bridge re-serializes what it parsed and compares the
252
+ length, which is what stops an `objectBcs` envelope from decoding as the struct
253
+ it wraps, and only a real layout can serialize.
254
+
255
+ So a codec that maps into your own domain types is a `BcsType` **composed with
256
+ `Schema.decodeTo`**, never a custom `parse`:
257
+
258
+ <!-- from: examples/extension-template/src/schema.ts -->
259
+
260
+ ```ts
261
+ export const SettlementContent = (typeOrigin: string) =>
262
+ SuiSchema.bcs(
263
+ SettlementBcs,
264
+ `${typeOrigin}::escrow::Settlement`
265
+ ).pipe(
266
+ Schema.decodeTo(
267
+ Settlement,
268
+ SchemaTransformation.transformOrFail<SettlementParts, typeof SettlementBcs.$inferType>({
269
+ decode: (fields, options) =>
270
+ // `transformOrFail`, not `transform`, because one of these mappings can
271
+ // fail: a `u64` of milliseconds is not necessarily a time. A `transform`
272
+ // whose body throws is a **defect**, which is not what a bad byte on the
273
+ // wire should be; failing with a `SchemaIssue` here is what makes it a
274
+ // `DecodeError` like any other.
275
+ Effect.map(
276
+ Effect.fromOption(
277
+ DateTime.make(Number(fields.settled_at_ms)),
278
+ () =>
279
+ new SchemaIssue.InvalidValue(
280
+ { message: `settled_at_ms ${fields.settled_at_ms} is not a time` },
281
+ fields,
282
+ options
283
+ )
284
+ ```
285
+
286
+ The domain class is an ordinary `Schema.Class`:
287
+
288
+ <!-- from: examples/extension-template/src/schema.ts -->
289
+
290
+ ```ts
291
+ export class Settlement extends Schema.Class<Settlement>("Settlement")({
292
+ escrowId: ObjectId,
293
+ settledAt: Schema.DateTimeUtc,
294
+ claimedBy: SuiAddress
295
+ }) {}
296
+ ```
297
+
298
+ **The halfway shape must be an explicit interface.** The transformation's source
299
+ type — what `decode` produces and `encode` consumes — is written out as its own
300
+ interface, as `src/schema.ts` does. Reaching for `typeof Settlement.Encoded` or
301
+ `typeof Settlement.Type` instead looks equivalent and is not: those name the
302
+ class's *own* two sides, which inverts the direction the transformation is being
303
+ inferred in, and the result does not compile — with an error about the wrong
304
+ side of the transformation, several frames away from the line that caused it.
305
+ Write the interface.
306
+
307
+ `decode` produces the target's field shape and the target schema does the rest,
308
+ so the `ObjectId` and `SuiAddress` brands are checked as part of the same
309
+ decode. Use `SchemaTransformation.transform` for a total mapping and
310
+ `transformOrFail` for one that can fail; a `transform` whose body *throws* is a
311
+ defect, and bad bytes deserve a failure. Either way a failure inside the domain
312
+ transform is still a **`DecodeError`** from `SuiSchema.decode` and from
313
+ `sui.getObject`, carrying the same `objectId` and `expectedType` — the domain
314
+ mapping is part of the boundary, not a step after it.
315
+
316
+ ### Generic Move types
317
+
318
+ A Move type with type parameters is a different tag per instantiation:
319
+ `pkg::composition::Composition<0x…::share::Share>`. You do not write a codec per
320
+ instantiation. Give the bridge the **bare** tag —
321
+ `pkg::composition::Composition` — and it matches every instantiation of it,
322
+ comparing `address::module::name` only. Give it a tag that *carries* type
323
+ arguments and it is compared in full, after normalization, so
324
+ `Coin<0x2::sui::SUI>` does not accept `Coin<…::usdc::USDC>`.
325
+
326
+ The same rule holds everywhere a Move type is compared: the `expectedType`
327
+ option of `getObject` / `getObjectOption` / `getObjects`, `SuiSchema.decode`'s
328
+ `actualType`, the `type` filter of `streamOwnedObjects`, and the fake's filter
329
+ in tests. The object keeps the type it actually has on `SuiObject.type`, so an
330
+ extension that cares which instantiation it read can still look.
331
+
332
+ ### Every type-shaped constant is a function of the package id
333
+
334
+ A Move type name **contains its package id**. So a codec, an owned-object
335
+ filter or a receipt type built from a module-level constant checks the wrong
336
+ type the moment a consumer configures a different package, and the symptom is
337
+ brutal: a correctly encoded object fails with `DecodeError`, and a claim that
338
+ applied on chain reports a missing receipt. The template derives all of them
339
+ from the id the service was built with:
340
+
341
+ <!-- from: examples/extension-template/src/schema.ts -->
342
+
343
+ ```ts
344
+ export const escrowType = (typeOrigin: string): string =>
345
+ `${typeOrigin}::escrow::Escrow`
346
+
347
+ /** The Move type of a claim receipt, which `claimFor` expects to be created. */
348
+ export const receiptType = (typeOrigin: string): string =>
349
+ `${typeOrigin}::escrow::Receipt`
350
+
351
+ /**
352
+ * `escrow::Escrow`, the object this extension reads, **as a function of the
353
+ * package it lives in**.
354
+ *
355
+ * A Move type name contains its package id, so a codec built from a hard-coded
356
+ * constant checks the wrong type the moment a consumer configures a different
357
+ * package: `getObject(id, { schema })` compares the object's tag before it
358
+ * parses a byte, and a correctly encoded object under the configured package
359
+ * fails with `DecodeError`. Every type-shaped constant in an extension takes
360
+ * the package id the service was built with, and the service passes its own.
361
+ *
362
+ * **Which package id.** The one that appears in a type name is the **type
363
+ * origin**: the package the type was *first* published in. Upgrading a package
364
+ * gives it a new id for *calls*, and the type origin does not move. So an
365
+ * extension over an upgraded package carries two ids — `packageId` for
366
+ * `moveCall` targets, `typeOrigin` for codecs, filters and receipt types — and
367
+ * they are the same value until the first upgrade. `Escrow.layer` takes both.
368
+ */
369
+ export const EscrowContent = (typeOrigin: string) => SuiSchema.bcs(
370
+ EscrowBcs,
371
+ escrowType(typeOrigin)
372
+ )
373
+ ```
374
+
375
+ **Which id, though.** The one inside a type name is the **type origin**: the
376
+ package the type was *first* published in. Upgrading a package gives it a new id
377
+ for `moveCall` targets and leaves every type name pointing at the original. So
378
+ an extension over an upgradeable package carries two: `packageId` for calls,
379
+ `typeOrigin` for codecs, filters and expected types. They are the same value
380
+ until the first upgrade, which is why `EscrowOptions.typeOrigin` defaults to
381
+ `packageId`.
382
+
383
+ ### Bytes you already have
384
+
385
+ Where you already have bytes — a `Stream` of envelopes, a dynamic field's value,
386
+ an event payload — `SuiSchema.decode(codec, bytes, { objectId?, expectedType?, actualType? })`
387
+ is the same decode `getObject` does, and produces the same `DecodeError` naming
388
+ the object and the type. Use it instead of hand-rolling
389
+ `Schema.decodeUnknownEffect(...).pipe(Effect.mapError(...))`. Pass `actualType`
390
+ when you know the type the bytes came from and the tag check runs here too:
391
+
392
+ <!-- from: examples/extension-template/src/Escrow.ts -->
393
+
394
+ ```ts
395
+ const stream = (owner: SuiAddress) =>
396
+ sui.streamOwnedObjects(owner, { type: ownedFilter }).pipe(
397
+ Stream.mapEffect((object) =>
398
+ // `SuiSchema.decode` is the same decode `sui.getObject({ schema })`
399
+ // does, for the places that already have bytes. Bytes that do not
400
+ // decode are a `DecodeError` naming the object and the type — not a
401
+ // transport failure, which is what a node that could not be reached
402
+ // is.
403
+ SuiSchema.decode(content, object.content, {
404
+ objectId: object.id,
405
+ // The type the object actually has. Give it and `SuiSchema.decode`
406
+ // runs the same tag check `getObject` does, under the same rule: a
407
+ // bare expected tag matches every instantiation of it, a
408
+ // parameterized one is compared in full.
409
+ actualType: object.type
410
+ }).pipe(Effect.map((content): EscrowObject => ({ ...object, content })))
411
+ )
412
+ )
413
+ ```
414
+
415
+ `expectedType` on `SuiSchema.bcs` is **optional**, and there are two cases that
416
+ have no tag to compare. A Move **return value**: `sui.view(recipe,
417
+ bcs.Address())` takes a bare `@mysten/bcs` layout, so nothing has to invent a
418
+ type for a codec that will never meet an object. And an **event payload** whose
419
+ Move type contains a package id the decoder does not know — an event decoder is
420
+ usually written once and used against whatever package the deployment
421
+ configured, so `SuiSchema.bcs(layout)` with no expected type is the intended
422
+ shape for events, not a shortcut. What still guards those bytes is the
423
+ re-serialize check the bridge always runs: a layout that parsed but whose
424
+ re-serialization is a different length is rejected, which is what stops an
425
+ envelope decoding as the struct it wraps.
426
+
427
+ ### Never `.make` a branded value from unvalidated input
428
+
429
+ `ObjectId.make`, `SuiAddress.make`, `StructTag.make` and friends **validate and
430
+ throw**. They are for a literal you wrote yourself, or a value that has already
431
+ been through a schema. A string that came from a node, a config file, a user or
432
+ an upstream package goes through `Schema.decodeUnknownEffect(ObjectId)` and
433
+ becomes a typed `DecodeError`; `.make` on it is a defect in a member whose error
434
+ union says it cannot fail.
435
+
436
+ 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.
439
+
440
+ When the error you would build *needs a field you do not have* — a `DecodeError`
441
+ wants an `objectId` and you are decoding an event payload with no object — that
442
+ is the signal to declare your own error, or to return an `Option`, rather than
443
+ to invent a value to satisfy the schema.
444
+
445
+ ### Dynamic fields, and the types their keys may be
446
+
447
+ `sui.streamDynamicFields(parent)` pages a parent's fields; there is no key
448
+ filter, because the node has none, so a caller filters the stream on
449
+ `entry.name.type`. Do that with **`SuiSchema.matchesType(expected, actual)`**,
450
+ which is the one Move-type rule this package uses everywhere and is safe on any
451
+ string:
452
+
453
+ <!-- inline -->
454
+
455
+ ```ts
456
+ import { Stream } from "effect"
457
+ import { SuiSchema } from "@unconfirmed/sui-effect"
458
+
459
+ const shares = sui.streamDynamicFields(parentId).pipe(
460
+ Stream.filter((entry) => SuiSchema.matchesType(shareKeyType, entry.name.type)),
461
+ Stream.mapEffect((entry) =>
462
+ SuiSchema.decode(ShareValue, entry.value.bcs, { actualType: entry.valueType })
463
+ )
464
+ )
465
+ ```
466
+
467
+ Do **not** reach for the SDK's `normalizeStructTag` here. A dynamic-field key is
468
+ legally a primitive — `u64`, `bool`, `address`, `vector<u8>` — and
469
+ `normalizeStructTag` throws on every one of them, so the obvious filter dies as
470
+ a defect on the first `u64` key in somebody's table.
471
+
472
+ A note on `Stream`: **`Stream.runCollect` returns a plain `Array` in Effect v4**,
473
+ not a `Chunk`. `.length` and `[0]` work; `Chunk.toReadonlyArray` does not exist
474
+ for it.
475
+
476
+ ### Absence, and batch reads
477
+
478
+ Absence is not always an error. `sui.getObjectOption(id, { schema })` is `None`
479
+ for a missing or deleted object, and an extension whose domain says "there may
480
+ be no profile yet" is right to return `Option` (or `null`, at a Promise
481
+ boundary) rather than inventing a `NotFound` error. Reserve an error for the
482
+ case where the caller asked for something that must exist.
483
+
484
+ `sui.getObjects(ids, opts)` returns a per-item `Result`, because one missing id
485
+ out of fifty is not a failed read. Two idioms, and you should pick deliberately:
486
+
487
+ - **soft** — the ones that are there are the answer:
488
+ `results.filter(Result.isSuccess).map((result) => result.success)`, or
489
+ `Result.getOrElse(result, () => fallback)` per item, or a `Map` keyed by id so
490
+ a caller can ask about one;
491
+ - **hard** — every id must be there: `sui.getObjectsOrFail(ids, opts)`, which
492
+ fails with the first item's error (`ObjectNotFound`, `ObjectDeleted`,
493
+ `ObjectUnavailable` or `DecodeError`) and otherwise hands back the objects in
494
+ the order of the ids.
495
+
496
+ Return the `Result` array to *your* consumers only when they can act on it;
497
+ otherwise pick one of the two above inside the extension and declare what you
498
+ picked in the member's error union.
499
+
500
+ **An extension never calls `SuiCore.executeTransaction`.** Writes go through
501
+ `Tx.submit` or `Tx.run`, so that every transaction on the platform gets the
502
+ journal, the default expiration, the sender lock and reconcile. This is not a
503
+ style rule: `executeTransaction` inside an extension is how a crashed process
504
+ leaves a transaction nobody can account for.
505
+
506
+ <!-- from: examples/extension-template/src/Escrow.ts -->
507
+
508
+ ```ts
509
+ const claimFor = Effect.fn("Escrow.claimFor")(function*(
510
+ id: ObjectId,
511
+ opts: { readonly signer: Signer }
512
+ ) {
513
+ const escrow = yield* get(id)
514
+ const executed = yield* Tx.run(claim(escrow), { signer: opts.signer })
515
+ // The transaction applied and gas was charged; what is missing is the
516
+ // receipt. That is what `UnexpectedEffects` means, and `outcome` puts it
517
+ // on "applied". Mapping it to `TransportError` would tell a wrapper the
518
+ // opposite — nothing happened, retry — about a claim that ran.
519
+ const created = yield* executed.expectCreated(receipt)
520
+ yield* notify(id, executed.digest)
521
+ return created
522
+ // `Tx.*` requires `Sui`, and the layer has one: providing it here is what
523
+ // keeps every member's requirement channel empty, which is what
524
+ // `SuiExtension.fromService` and every consumer expect.
525
+ }, Effect.provideService(Sui, sui))
526
+ ```
527
+
528
+ The last line is the detail that is easy to get wrong. `Tx.*` declares
529
+ `R = Sui`, and a service member must have **no requirements at all** — a
530
+ consumer yields your service and calls it. The layer already holds a `Sui`, so
531
+ provide it to the members that need one, as an extra argument to `Effect.fn`
532
+ rather than a `.pipe` on its result.
533
+
534
+ ### Reaching `SuiCore`
535
+
536
+ `Sui` carries the `SuiCore` it was built over as `sui.core`, so an extension can
537
+ reach a method the opinionated tier does not expose — or the SDK client object
538
+ itself, through `sui.core.use` — without adding `SuiCore` to its own
539
+ requirements. Reach for it for a field `Sui` does not expose, and for nothing
540
+ else.
541
+
542
+ ## 4. Recipe fragments versus submissions
543
+
544
+ A `Recipe` is `(tx: Transaction) => void`: synchronous, replayable, free of
545
+ dependencies. **An extension that adds commands to a transaction exposes
546
+ fragments, not submissions**, so a consumer can compose several extensions into
547
+ one programmable transaction and submit once.
548
+
549
+ <!-- from: examples/extension-template/src/Escrow.ts -->
550
+
551
+ ```ts
552
+ const claim = (escrow: EscrowObject): Recipe => (tx) => {
553
+ tx.moveCall({
554
+ target: `${packageId}::escrow::claim`,
555
+ arguments: [tx.object(escrow.id), tx.pure.u64(escrow.content.amount)]
556
+ })
557
+ }
558
+ ```
559
+
560
+ `Recipe` is the **top-level** draft type: what `Tx.build`, `Tx.run` and
561
+ `sui.simulate` take. A *fragment* need not return `void` — the common shape for
562
+ an object model is a fragment that returns the builder arguments it created, so
563
+ a later command can consume them:
564
+
565
+ ```text
566
+ const createComposition = (tx: Transaction) => {
567
+ const [composition, cap] = tx.moveCall({ target: `${pkg}::composition::new`, arguments: [] })
568
+ return { composition, cap }
569
+ }
570
+ ```
571
+
572
+ `(tx) => A` is assignable to `Recipe` because TypeScript ignores a return value
573
+ where `void` is expected, so such a fragment is still usable as a top-level
574
+ recipe — but that is a convenience, not the contract. Say in your own types
575
+ which functions are fragments returning arguments and which are recipes.
576
+
577
+ A consumer composes fragments by calling them in order on one transaction:
578
+
579
+ <!-- from: examples/extension-template/test/escrow.test.ts -->
580
+
581
+ ```ts
582
+ test("the recipe fragment composes into a consumer's transaction", async () => {
583
+ const commands = await provide(
584
+ Effect.gen(function*() {
585
+ const escrow = yield* Escrow
586
+ const object = yield* escrow.get(ESCROW_ID)
587
+ const tx = new Transaction()
588
+ // A consumer composes fragments from several extensions and submits once.
589
+ escrow.claim(object)(tx)
590
+ escrow.claim(object)(tx)
591
+ return tx.getData().commands
592
+ })
593
+ )
594
+ expect(commands).toHaveLength(2)
595
+ expect(commands[0]?.$kind).toBe("MoveCall")
596
+ })
597
+ ```
598
+
599
+ Notice `claimFor`: the receipt comes from `executed.expectCreated(...)` with no
600
+ `mapError` at all. The claim is on chain and gas was charged; only the receipt is
601
+ missing, which is exactly what `UnexpectedEffects` means, and its outcome is
602
+ `"applied"`. Adding it to the member's declared union is the honest fix; mapping
603
+ it to something with outcome `"not_applied"` is not.
604
+
605
+ An extension submits on the consumer's behalf only when that is its purpose —
606
+ onara's sponsor-and-run, this template's `claimFor` — and when it does, it still
607
+ exposes the recipe-level pieces, so a consumer who wants one transaction instead
608
+ of two is not locked out.
609
+
610
+ ## 5. Signers are parameters
611
+
612
+ An extension never holds a consumer's signer in its layer. It holds its own
613
+ credentials — a sponsor key, an API key — and takes the consumer's signer as an
614
+ argument, because a layer field cannot say *which* credential a call meant and
615
+ one process may legitimately hold two.
616
+
617
+ <!-- from: examples/extension-template/src/Escrow.ts -->
618
+
619
+ ```ts
620
+ export interface EscrowOptions {
621
+ /** The published package id, which is what `moveCall` targets name. */
622
+ readonly packageId: string
623
+ /**
624
+ * The type origin: the package the Move **types** were first published in,
625
+ * which is what appears inside `pkg::escrow::Escrow`.
626
+ *
627
+ * Defaults to `packageId`, which is right until the package is upgraded —
628
+ * an upgrade gives the package a new id for calls and leaves every type name
629
+ * pointing at the original. Set it then, and codecs, owned-object filters and
630
+ * the receipt type keep checking the type that exists.
631
+ */
632
+ readonly typeOrigin?: string
633
+ /** The operator's settlement service. */
634
+ readonly url: string
635
+ /** The extension's own credential — never the consumer's. */
636
+ readonly apiKey: Redacted.Redacted<string>
637
+ }
638
+ ```
639
+
640
+ **A sponsored write needs two of them.** When the transaction's gas owner is not
641
+ its sender, both parties sign; one signature on such bytes is something a
642
+ validator rejects outright. `Tx.run(recipe, { signer, gasOwner, sponsor })`
643
+ takes the sponsor's `Signer` and co-signs, and refuses with `SigningError` —
644
+ before anything is built — when a gas owner has no sponsor to go with it. The
645
+ same check runs on the addresses read back out of the built bytes, so a recipe
646
+ that set its own gas owner (anything built with `Tx.sponsored`) is caught too.
647
+ An extension whose two parties cannot both sign in one process — the sponsor is
648
+ a remote service, the sender is a wallet — uses the explicit lifecycle instead:
649
+ `Tx.build`, `Tx.sign`, hand the bytes over, `Tx.cosign`, `Tx.submit`.
650
+
651
+ ## 6. Layers
652
+
653
+ Following the house convention: `layer(opts)` for the live one, `layerConfig`
654
+ for the environment, `layerTest` for tests, and — for an extension over a
655
+ published Move package — `layerBundled`, which picks the deployment from the
656
+ network the client is already on.
657
+
658
+ The rule is **not** "requires `Sui` and nothing else". It is: *requires nothing
659
+ the consumer's client could have provided*. `SuiExtension.fromService` accepts a
660
+ `Layer<Self, E, Sui | SuiCore>` and builds both tiers over the client `$extend`
661
+ was called on, so an extension never constructs its own client — two clients
662
+ would mean two connections and two chain-identifier checks. Anything else your
663
+ layer needs — an `HttpClient`, a `SuiGraphQL`, another extension's service — you
664
+ provide **inside** your layer (or in the function that builds the registration),
665
+ so what comes out still fits the bound. Section 8 shows that with a second
666
+ service; the shape is `Layer.effect(Self, make).pipe(Layer.provide(Dependency.layer(…)))`.
667
+
668
+ <!-- from: examples/extension-template/src/Escrow.ts -->
669
+
670
+ ```ts
671
+ static readonly layer = (options: EscrowOptions): Layer.Layer<Escrow, never, Sui> =>
672
+ Layer.effect(
673
+ Escrow,
674
+ make({
675
+ packageId: options.packageId,
676
+ ...(options.typeOrigin === undefined ? {} : { typeOrigin: options.typeOrigin }),
677
+ api: settlementApi({ url: options.url, apiKey: Redacted.value(options.apiKey) })
678
+ })
679
+ )
680
+ ```
681
+
682
+ `layerConfig` reads a prefixed namespace through `Config.nested`, and every
683
+ secret is `Config.redacted`, so it cannot reach a log line. **Every value that
684
+ has a schema is read through it**, with `Config.schema(ObjectId, "PACKAGE_ID")`
685
+ rather than `Config.nonEmptyString`: an override that is not an object id then
686
+ fails where it was set, naming the variable, instead of being carried into every
687
+ `moveCall` target and every codec and surfacing three calls later as a Move
688
+ abort nobody can trace back to an environment variable. The template has a test
689
+ for the malformed case; so should you.
690
+
691
+ Two things not to promise in the docs you write around it. `Config.option` turns
692
+ an **empty** variable into "unset", so `FOO=""` takes the default rather than
693
+ failing — do not write "an empty value is a `ConfigError`" above a
694
+ `Config.option`. And `Effect.withConfigProvider` does not exist in Effect v4
695
+ rc.112: a test provides the provider like anything else, with
696
+ `ConfigProvider.layer(ConfigProvider.fromEnvRecord({ … }))` or
697
+ `Effect.provideService(effect, ConfigProvider.ConfigProvider, provider)`.
698
+
699
+ <!-- from: examples/extension-template/src/Escrow.ts -->
700
+
701
+ ```ts
702
+ static readonly layerConfig: Layer.Layer<Escrow, Config.ConfigError, Sui> = Layer.unwrap(
703
+ Effect.gen(function*() {
704
+ const options = yield* Config.all({
705
+ packageId: Config.schema(ObjectId, "PACKAGE_ID").pipe(
706
+ Config.withDefault(ObjectId.make(ESCROW_PACKAGE))
707
+ ),
708
+ url: Config.nonEmptyString("URL"),
709
+ apiKey: Config.redacted("API_KEY")
710
+ }).pipe(Config.nested("ESCROW"))
711
+ return Escrow.layer(options)
712
+ })
713
+ )
714
+ ```
715
+
716
+ `layerTest` is the **real service** over a fake of whatever the extension owns
717
+ that is not Sui — here the operator's settlement service. It is not a mock of
718
+ the extension: a test must exercise the code that ships.
719
+
720
+ <!-- from: examples/extension-template/src/Escrow.ts -->
721
+
722
+ ```ts
723
+ static readonly layerTest = (
724
+ state: { readonly settled?: boolean } = {}
725
+ ): Layer.Layer<Escrow, never, Sui> =>
726
+ Layer.effect(
727
+ Escrow,
728
+ make({ packageId: ESCROW_PACKAGE, api: fakeApi(state.settled ?? true) })
729
+ )
730
+ ```
731
+
732
+ An extension that owns nothing but `Sui` has nothing to fake, and then
733
+ `layerTest = layer(fixedDeployment)` — a package id and no more. That degenerate
734
+ case is expected, not a smell: the harness in section 10 fakes the chain, the
735
+ extension's own layer has nothing left to fake, and the checklist's "a
736
+ `layerTest` its own tests do not use" is about a `layerTest` nobody exercises,
737
+ not about one that is thin.
738
+
739
+ ### A layer that picks a bundled deployment
740
+
741
+ A package id is per network, and a consumer who has already chosen a network by
742
+ building a client should not have to carry a table of them. `Layer.unwrap` lets
743
+ a layer read `Sui` before deciding which layer to be:
744
+
745
+ <!-- from: examples/extension-template/src/Escrow.ts -->
746
+
747
+ ```ts
748
+ static readonly layerBundled = (
749
+ options: { readonly apiKey: Redacted.Redacted<string> }
750
+ ): Layer.Layer<Escrow, EscrowUnsupportedNetwork, Sui> =>
751
+ Layer.unwrap(
752
+ Effect.gen(function*() {
753
+ const sui = yield* Sui
754
+ const deployment = DEPLOYMENTS[sui.network]
755
+ if (deployment === undefined) {
756
+ return yield* new EscrowUnsupportedNetwork({ network: sui.network })
757
+ }
758
+ return Escrow.layer({ ...deployment, apiKey: options.apiKey })
759
+ })
760
+ )
761
+ ```
762
+
763
+ <!-- from: examples/extension-template/src/Escrow.ts -->
764
+
765
+ ```ts
766
+ export const DEPLOYMENTS: Readonly<Record<string, EscrowDeployment>> = {
767
+ testnet: { packageId: ESCROW_PACKAGE, url: "https://settlement.testnet.example" },
768
+ mainnet: { packageId: ESCROW_PACKAGE, url: "https://settlement.example" }
769
+ }
770
+ ```
771
+
772
+ A network with no entry is a **typed failure** of your own — here
773
+ `EscrowUnsupportedNetwork`, outcome `not_applied` — not an `undefined` package
774
+ id that surfaces as a Move abort three calls later. This is what a predecessor's
775
+ `DeploymentError` becomes.
776
+
777
+ <!-- from: examples/extension-template/src/errors.ts -->
778
+
779
+ ```ts
780
+ export class EscrowUnsupportedNetwork extends Schema.TaggedError<EscrowUnsupportedNetwork>()(
781
+ "escrow/EscrowUnsupportedNetwork",
782
+ { network: Schema.String }
783
+ ) {
784
+ readonly outcome: Outcome = "not_applied"
785
+ }
786
+ ```
787
+
788
+ And when the only configuration *is* the package id, `layerBundled` is the layer
789
+ to ship and `layerConfig` is the override: something for a private deployment, a
790
+ package under test, a credential the table cannot hold. `layerConfig` earns its
791
+ place when configuration carries more than the network already implies — it is
792
+ not a rule that every extension must read an environment variable.
793
+
794
+ ## 7. The Promise face
795
+
796
+ Never maintain a Promise API beside the Effect one; derive it.
797
+
798
+ <!-- from: examples/extension-template/src/extension.ts -->
799
+
800
+ ```ts
801
+ export const escrow = (options: EscrowRegistrationOptions) =>
802
+ SuiExtension.fromService(Escrow, {
803
+ name: "escrow",
804
+ layer: Escrow.layer(options),
805
+ warm: options.chainId === undefined ? {} : { chainId: options.chainId }
806
+ })
807
+ ```
808
+
809
+ `register(client)` does no work until the first call. Then it builds one
810
+ `ManagedRuntime` over your layer and a **base shared per client** —
811
+ `SuiCore.layerFromClient(client)` plus `Sui.layerNoDeps` — so the extension and
812
+ the consumer share one transport and one chain-identifier check, and so do two
813
+ different extensions on the same client. After that:
814
+
815
+ - an `Effect` member is a zero-argument method returning a `Promise`;
816
+ - a function returning an `Effect` keeps its arguments and returns a `Promise`;
817
+ - a `Stream` is an `AsyncIterable`, usable in `for await`;
818
+ - a nested namespace is mapped recursively;
819
+ - a plain value passes through;
820
+ - a rejection is **the same tagged error instance**, so a Promise consumer can
821
+ still switch on `_tag` and read `outcome`;
822
+ - `dispose()` releases everything the layer acquired.
823
+
824
+ ### Synchronous members, `$ready` and `warm`
825
+
826
+ This is the part that bites. `PromiseFace` types an `Effect` member as
827
+ Promise-returning and leaves everything else alone: a recipe builder
828
+ `(p: Params) => Recipe` is still `(p: Params) => Recipe` on the face, and
829
+ `packageId` is still a `string`. But **until the runtime exists there is no
830
+ service object**, so nothing knows what a member is, and a placeholder is not a
831
+ `Recipe` and not a string.
832
+
833
+ An `Effect` member and a `Stream` member both work cold, because the face
834
+ promises a `Promise` for one and an `AsyncIterable` for the other and a cold
835
+ call can be both at once: what it returns is a thenable *and* an async iterable,
836
+ so `await client.status()` and `for await (const x of client.owned.stream(a))`
837
+ are each right before anything has been awaited.
838
+
839
+ So a synchronous member used before the runtime exists fails with
840
+ `ExtensionNotReady`, naming itself — a value read as a string throws, a
841
+ synchronous call rejects — instead of quietly handing back a `Promise` where the
842
+ type says `Recipe`. (That is the bug worth naming: the old behaviour worked on
843
+ the *second* call, once the member had become real, so it survived review and
844
+ broke in production.) Two cures, both yours to choose:
845
+
846
+ - **`await client.<name>.$ready()`** once after `$extend`. It builds the runtime
847
+ and resolves the service; every member is the real thing from then on. It is
848
+ idempotent and free after the first time.
849
+ - **`warm`**, which does the same synchronously inside `register`, so the
850
+ consumer has to do nothing at all:
851
+
852
+ <!-- from: examples/extension-template/src/Platform.ts -->
853
+
854
+ ```ts
855
+ export const platform = (options: PlatformRegistrationOptions) =>
856
+ SuiExtension.fromService(Platform, {
857
+ name: "platform",
858
+ layer: Platform.layer(options),
859
+ warm: options.chainId === undefined ? {} : { chainId: options.chainId }
860
+ })
861
+ ```
862
+
863
+ `warm` has two conditions and both are enforced. The layer must not perform an
864
+ asynchronous step — a layer that reads the network at build cannot be built
865
+ synchronously and `register` throws. And the chain identifier is **taken, not
866
+ read**: `warm.chainId`, or `sui.chainId`, or the built-in entry for `mainnet`
867
+ and `testnet`; on `devnet`, `localnet` or a custom network, `warm` without a
868
+ `chainId` throws rather than guess.
869
+
870
+ Be precise about what "taken, not read" costs. The node is not asked at
871
+ registration, and it is **not asked later either**: the pinned id is what
872
+ `Sui.chainId` reports for the life of the registration, and the first time the
873
+ node is consulted at all is the extension's own first call — which does not
874
+ check the identifier. So a `warm` registration never detects a node on another
875
+ chain. What catches it is the chain itself: `Tx.build` stamps that id on the
876
+ transaction's expiration and a validator refuses bytes signed for another chain.
877
+ Register lazily when the assertion is what you want.
878
+
879
+ **Thread the chain id through your registration options**, the way
880
+ `src/extension.ts` and `src/Platform.ts` both do, rather than relying on the
881
+ built-in table. It is what makes the face work on `devnet` and `localnet`, and
882
+ it is what lets every registration on one client agree — see the next
883
+ paragraph. The template has a test for the warm face on a network with no
884
+ built-in chain id; a conversion should have one too.
885
+
886
+ If your extension's surface is entirely `Effect` and `Stream` members, none of
887
+ this applies: the lazy default is right and the first `await` builds everything.
888
+
889
+ ### Non-plain values are leaves, and plain ones are not
890
+
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.
896
+
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.
906
+
907
+ ### The rest of the contract
908
+
909
+ `fromService` is generic in the registration name, so `client.escrow` is a
910
+ property of the extended client's type — no cast, and no `| undefined` under
911
+ `noUncheckedIndexedAccess`. `options.sui` pins the chain identifier the node
912
+ must report, which is how an extension whose deployment names a custom network's
913
+ `chainIdentifier` refuses to run against another chain. And `$dispose()` (still
914
+ available as `dispose()`) is not final: it releases what the layer acquired and
915
+ forgets the runtime, and the next call builds a fresh one, so dispose when the
916
+ consumer is done rather than between calls. Registering the same extension
917
+ twice, or on two clients, still gives two independent runtimes and two layer
918
+ builds — two copies of whatever *your* layer holds.
919
+
920
+ **The base is shared per client per chain id, and it matters more than it
921
+ sounds.** `Sui` owns the sender lock: one semaphore per address, which is what
922
+ stops two `Tx.run`s from selecting the same gas coin. When each registration
923
+ built its own `Sui`, two extensions on one client had two lock maps and could do
924
+ exactly that, and "register each extension once" did not help.
925
+
926
+ Every registration on one client whose **effective chain id** is the same —
927
+ `warm.chainId`, else `sui.chainId`, else the built-in entry for the network —
928
+ shares one `Sui`, one transport and one lock map. The key is the chain, not the
929
+ registration's style, so a `warm` registration and a lazy one on the same chain
930
+ do share; keying them apart is how the template's own pair used to end up with
931
+ two lock maps. A registration that pins a *different* chain id is asking for a
932
+ different `Sui` and gets one, on purpose.
933
+
934
+ Because a `warm` registration has to build synchronously, the shared base for a
935
+ known chain id is the **pinned** one, and a lazy registration joining it
936
+ performs its own `getChainIdentifier` assertion as one extra layer — run once
937
+ however many lazy registrations join, so nothing is lost and nothing is
938
+ duplicated.
939
+
940
+ So: **register every extension on a client the same way and with the same chain
941
+ id**, or accept two of everything. It is reference counted: the base is built by
942
+ the first registration that needs it and released when the **last** one is
943
+ disposed, so `$dispose()` on one extension never tears the transport out from
944
+ under another.
945
+ `examples/extension-consumer.ts` in this repository shows both consumers of one
946
+ extension side by side.
947
+
948
+ ## 8. Composing extensions
949
+
950
+ A platform SDK is rarely one Move package. It is a service per package, plus a
951
+ service on top that consumers actually hold, and the top one exposes the others
952
+ as **namespaces** — `client.platform.escrow.get(id)` — rather than making a
953
+ consumer register three extensions and remember which is which.
954
+
955
+ Two rules make that work, and both are in one file:
956
+
957
+ <!-- from: examples/extension-template/src/Platform.ts -->
958
+
959
+ ```ts
960
+ export interface PlatformService {
961
+ /**
962
+ * The escrow package's whole surface, as a namespace.
963
+ *
964
+ * It is the dependency service's own object, unchanged: no wrapper methods to
965
+ * keep in step, and the Promise face maps it recursively, so
966
+ * `client.platform.escrow.get(id)` works for a Promise consumer exactly as
967
+ * `platform.escrow.get(id)` does for an Effect one.
968
+ */
969
+ readonly escrow: EscrowService
970
+ /**
971
+ * One operation that spans the packages this platform composes.
972
+ *
973
+ * The error union is the composition's: this package's own errors plus
974
+ * whatever the packages underneath declare. Nothing is swallowed and nothing
975
+ * is widened.
976
+ *
977
+ * Fails with: `EscrowNotFound`, `EscrowSettlementUnknown`, `DecodeError`,
978
+ * `UnexpectedEffects`, and everything `Tx.run` declares.
979
+ */
980
+ readonly claimEverything: (
981
+ ids: ReadonlyArray<ObjectId>,
982
+ opts: { readonly signer: Signer }
983
+ ) => Effect.Effect<ReadonlyArray<ChangedRef>, ClaimForError>
984
+ }
985
+ ```
986
+
987
+ The dependency's service object is exposed **as it is**. There are no wrapper
988
+ methods to keep in step, and the Promise face maps a plain object of members
989
+ recursively, so `client.platform.escrow.get(id)` works for a Promise consumer
990
+ exactly as `platform.escrow.get(id)` does for an Effect one.
991
+
992
+ <!-- from: examples/extension-template/src/Platform.ts -->
993
+
994
+ ```ts
995
+ static readonly layer = (options: PlatformOptions): Layer.Layer<Platform, never, Sui> =>
996
+ Layer.effect(Platform, make).pipe(Layer.provide(Escrow.layer(options)))
997
+ ```
998
+
999
+ `Layer.provide(Escrow.layer(options))` is the second rule. The composition's own
1000
+ construction requires `Escrow`; providing it here means the layer that comes out
1001
+ requires only `Sui`, which is what `SuiExtension.fromService` can satisfy from
1002
+ the consumer's client. An extension's dependencies are provided inside its
1003
+ layer — the consumer never learns they exist.
1004
+
1005
+ Two services in one package is exactly the case the "one service per package"
1006
+ rule allows for: the second one is the composition. Keep the dependency
1007
+ published and usable on its own, so a consumer who wants only that package is
1008
+ not forced to take the platform.
1009
+
1010
+ The same shape holds for a dependency that is not an extension at all — a
1011
+ `SuiGraphQL` client, an `HttpClient`, your own operator service: yield it in
1012
+ `make`, provide its layer in `layer`.
1013
+
1014
+ ## 9. Wrapping an upstream Promise package
1015
+
1016
+ For upstream SDKs we do not own (suins, deepbook, whatever comes next) we do not
1017
+ lift their Promise surface generically. We maintain an Effect-native extension
1018
+ per package, built to this contract, that depends on the upstream package for
1019
+ its logic and hides it completely. There are exactly two shapes.
1020
+
1021
+ **A call that needs the SDK client object** goes through `sui.core.use`, which
1022
+ runs the error mapper and forwards the `AbortSignal`:
1023
+
1024
+ <!-- from: examples/extension-template/src/Escrow.ts -->
1025
+
1026
+ ```ts
1027
+ const feeCollector = sui.core
1028
+ .use((client, signal) => api.resolveFeeCollector(client, packageId, signal))
1029
+ .pipe(
1030
+ Effect.catchTag(
1031
+ ["ObjectNotFound", "ObjectDeleted", "ObjectUnavailable", "TransactionNotFound", "SimulationFailed"],
1032
+ (error) => Effect.fail(transport("escrow.feeCollector")(error))
1033
+ ),
1034
+ // Upstream answered with `unknown`; it becomes a sui-effect schema
1035
+ // before anything else in this package sees it. A value that does not
1036
+ // decode is a `DecodeError` and stays one: it says which boundary was
1037
+ // wrong, where `TransportError` would claim the node was unreachable.
1038
+ Effect.flatMap((raw) =>
1039
+ decodeAddress(raw).pipe(
1040
+ Effect.mapError((issue) =>
1041
+ new DecodeError({ expectedType: "SuiAddress", issue: issue.message })
1042
+ )
1043
+ )
1044
+ ),
1045
+ Effect.withSpan("Escrow.feeCollector")
1046
+ )
1047
+ ```
1048
+
1049
+ **A pure upstream helper** goes through `Effect.tryPromise` with a mapping
1050
+ function — never a bare `catch: (cause) => cause`, which would put `unknown` in
1051
+ your error channel — and with the signal forwarded, so interruption cancels the
1052
+ request:
1053
+
1054
+ <!-- from: examples/extension-template/src/Escrow.ts -->
1055
+
1056
+ ```ts
1057
+ const notify = Effect.fn("Escrow.notify")(function*(escrowId: ObjectId, digest: Digest) {
1058
+ const unknownOutcome = (message: string) =>
1059
+ new EscrowSettlementUnknown({ escrowId, digest, message })
1060
+ // A pure upstream helper: `Effect.tryPromise` with a mapping function,
1061
+ // and the signal forwarded so an interrupted claim cancels the request.
1062
+ const response = yield* Effect.tryPromise({
1063
+ try: (signal) => api.notifyClaim({ escrowId, digest }, signal),
1064
+ catch: (cause) => unknownOutcome(`the settlement service failed: ${String(cause)}`)
1065
+ })
1066
+ const settlement = yield* decodeSettlement(response).pipe(
1067
+ Effect.mapError((issue) =>
1068
+ unknownOutcome(`the settlement service answered something unreadable: ${issue.message}`)
1069
+ )
1070
+ )
1071
+ if (settlement.status !== "settled") {
1072
+ return yield* unknownOutcome("the settlement service is still holding the claim")
1073
+ }
1074
+ })
1075
+ ```
1076
+
1077
+ In both cases the upstream answer is **narrowed to a @unconfirmed/sui-effect schema before
1078
+ anything else sees it**. Upstream types are never re-exported: the template's
1079
+ `src/upstream.ts` is absent from `src/index.ts`, and `SettlementResponse` never
1080
+ reaches a consumer. That narrowing is what makes the wrapper worth having —
1081
+ precise errors and stable types instead of whatever the upstream ships next
1082
+ release.
1083
+
1084
+ ### If your extension reads GraphQL
1085
+
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:
1089
+
1090
+ <!-- inline -->
1091
+
1092
+ ```ts
1093
+ import { SuiGraphQL } from "@unconfirmed/sui-effect"
1094
+
1095
+ const chainId = SuiGraphQL.query(
1096
+ (client) => client.query({ query: "{ chainIdentifier }", variables: {} }),
1097
+ "chainIdentifier"
1098
+ )
1099
+ // Effect<…, GraphQLUnavailable | TransportError, SuiGraphQL>
1100
+ ```
1101
+
1102
+ A rejection from `SuiGraphQL.layerUnavailable` is already a
1103
+ `GraphQLUnavailable` and is passed through unchanged; anything else becomes
1104
+ `TransportError.fromUnknown(method, cause)`. Deriving that by hand in every
1105
+ member is how the passthrough gets forgotten and "there is no endpoint
1106
+ configured" arrives as an unclassified transport failure.
1107
+
1108
+ ## 10. Testing
1109
+
1110
+ `@unconfirmed/sui-effect/testing` is the whole harness. An extension's tests need nothing
1111
+ else: no network, no HTTP mock, no hand-rolled client.
1112
+
1113
+ <!-- from: examples/extension-template/test/escrow.test.ts -->
1114
+
1115
+ ```ts
1116
+ const provide = <A, E>(
1117
+ effect: Effect.Effect<A, E, Escrow | Sui | SuiCore | SuiCoreFake | TestClock.TestClock>,
1118
+ state: { readonly settled?: boolean } = {}
1119
+ ) =>
1120
+ Effect.runPromise(
1121
+ Effect.provide(
1122
+ effect,
1123
+ Layer.mergeAll(
1124
+ layerExtensionTest(Escrow.layerTest(state), script),
1125
+ // The program's own clock, for retries and timeouts. The chain's clock
1126
+ // is `SuiTest.setClock`.
1127
+ TestClock.layer(),
1128
+ // The default journal is a process-wide memory journal, so a test that
1129
+ // submits provides its own to stay isolated.
1130
+ Journal.layerMemory
1131
+ ),
1132
+ { local: true }
1133
+ )
1134
+ )
1135
+ ```
1136
+
1137
+ `layerExtensionTest(extensionLayer, script)` is the extension's layer over
1138
+ `layerTest(script)`, which is the **real** `Sui` over the in-memory `SuiCore`.
1139
+ So a test exercises the production high tier: the include sets, the BCS bridge,
1140
+ the chunked batch reads, the sender lock and every `Tx` step.
1141
+
1142
+ The `script` is what the fake serves — objects with real BCS content, gas coins,
1143
+ and scripted outcomes for simulate, execute and `getTransaction`:
1144
+
1145
+ <!-- from: examples/extension-template/test/escrow.test.ts -->
1146
+
1147
+ ```ts
1148
+ const script = {
1149
+ objects: [escrowObject("5")],
1150
+ coins: [
1151
+ {
1152
+ objectId: padded("c01"),
1153
+ version: "2",
1154
+ digest: "11111111111111111111111111111111",
1155
+ type: `0x2::coin::Coin<0x2::sui::SUI>`,
1156
+ balance: "1000000000",
1157
+ owner,
1158
+ previousTransaction: null
1159
+ } as unknown as SuiClientTypes.Coin
1160
+ ],
1161
+ execute: [
1162
+ FakeOutcome.succeed({
1163
+ created: [{ objectId: RECEIPT_ID, type: RECEIPT_TYPE, version: 4n, owner }],
1164
+ mutated: [{ objectId: ESCROW_ID, type: `${ESCROW_PACKAGE}::escrow::Escrow`, version: 4n, owner }]
1165
+ })
1166
+ ]
1167
+ }
1168
+ ```
1169
+
1170
+ `SuiTest` drives the fake from inside an `Effect`: `putObject`, `bumpVersion`,
1171
+ `deleteObject`, `setClock` (the chain's clock, which is what `Tx.build` bounds a
1172
+ transaction against — Effect's `TestClock` drives the program's own time),
1173
+ `scriptExecute` / `scriptSimulate` / `scriptGetTransaction`, and `calls` for
1174
+ asserting what was actually sent.
1175
+
1176
+ <!-- from: examples/extension-template/test/escrow.test.ts -->
1177
+
1178
+ ```ts
1179
+ test("claimFor submits once and returns the receipt", async () => {
1180
+ const { executes, receipt } = await provide(
1181
+ Effect.gen(function*() {
1182
+ const escrow = yield* Escrow
1183
+ const receipt = yield* escrow.claimFor(ESCROW_ID, { signer })
1184
+ const executes = yield* SuiTest.calls("executeTransaction")
1185
+ return { receipt, executes: executes.length }
1186
+ })
1187
+ )
1188
+ expect(String(receipt.id)).toBe(RECEIPT_ID)
1189
+ expect(String(receipt.type)).toBe(RECEIPT_TYPE)
1190
+ expect(executes).toBe(1)
1191
+ })
1192
+ ```
1193
+
1194
+ <!-- from: examples/extension-template/test/escrow.test.ts -->
1195
+
1196
+ ```ts
1197
+ test("the version the fake serves is the version the extension reads", async () => {
1198
+ const versions = await provide(
1199
+ Effect.gen(function*() {
1200
+ const escrow = yield* Escrow
1201
+ const before = yield* escrow.get(ESCROW_ID)
1202
+ yield* SuiTest.bumpVersion(ESCROW_ID)
1203
+ const after = yield* escrow.get(ESCROW_ID)
1204
+ return [before.version, after.version]
1205
+ })
1206
+ )
1207
+ expect(versions.map(Number)).toEqual([3, 4])
1208
+ })
1209
+ ```
1210
+
1211
+ There are two clocks, and an extension test usually needs both. Effect's
1212
+ `TestClock` drives the program's own time — every sleep, every retry schedule,
1213
+ every `Effect.timeout` — so a test never waits. `SuiTest.setClock` moves the
1214
+ chain's Clock object `0x6`, which is what `Tx.build` reads to bound a
1215
+ transaction. Both are provided by the test's own layer, alongside
1216
+ `Journal.layerMemory`: the default journal is a process-wide memory journal, and
1217
+ a test that submits provides its own to stay isolated.
1218
+
1219
+ <!-- from: examples/extension-template/test/escrow.test.ts -->
1220
+
1221
+ ```ts
1222
+ test("a retryable transport failure re-sends the identical bytes", async () => {
1223
+ const { attempts, bytes } = await provide(
1224
+ Effect.gen(function*() {
1225
+ const escrow = yield* Escrow
1226
+ yield* SuiTest.scriptExecute([
1227
+ FakeOutcome.transportError("UNAVAILABLE"),
1228
+ FakeOutcome.succeed({
1229
+ created: [{ objectId: RECEIPT_ID, type: RECEIPT_TYPE, version: 4n, owner }]
1230
+ })
1231
+ ])
1232
+ // The resubmit schedule sleeps, so the test drives the clock rather
1233
+ // than waiting.
1234
+ const fiber = yield* Effect.forkChild(escrow.claimFor(ESCROW_ID, { signer }))
1235
+ yield* TestClock.adjust("1 minute")
1236
+ yield* Fiber.join(fiber)
1237
+ const sent = yield* SuiTest.calls("executeTransaction")
1238
+ return {
1239
+ attempts: sent.length,
1240
+ bytes: new Set(
1241
+ sent.map((call) =>
1242
+ String((call.options as { readonly transaction: Uint8Array }).transaction)
1243
+ )
1244
+ ).size
1245
+ }
1246
+ })
1247
+ )
1248
+ expect(attempts).toBe(2)
1249
+ // The same bytes both times: `Tx.submit` never rebuilds.
1250
+ expect(bytes).toBe(1)
1251
+ })
1252
+ ```
1253
+
1254
+ ### Your own fake beside the harness
1255
+
1256
+ `layerExtensionTest` composes: the first argument is *your* layer, and your
1257
+ layer is free to carry a fake of its own. The template's `Escrow.layerTest`
1258
+ holds an in-memory settlement service; a platform composed over it carries the
1259
+ same fake one level down:
1260
+
1261
+ <!-- from: examples/extension-template/test/escrow.test.ts -->
1262
+
1263
+ ```ts
1264
+ test("the dependency's surface is a namespace on the composition", async () => {
1265
+ const amount = await Effect.runPromise(
1266
+ Effect.provide(
1267
+ Effect.flatMap(Platform, (platform) => platform.escrow.get(ESCROW_ID)),
1268
+ Layer.mergeAll(
1269
+ // The composition's own test layer over the harness: one fake for the
1270
+ // chain, and the dependency's own fake for its operator service.
1271
+ layerExtensionTest(Platform.layerTest({ settled: true }), script),
1272
+ Journal.layerMemory
1273
+ ),
1274
+ { local: true }
1275
+ ).pipe(Effect.map((escrow) => escrow.content.amount))
1276
+ )
1277
+ expect(amount).toBe("5")
1278
+ })
1279
+ ```
1280
+
1281
+ So an extension with a `SuiGraphQL` dependency provides
1282
+ `SuiGraphQL.layer(fakeClient)` (or `SuiGraphQL.layerUnavailable`, to test the
1283
+ path where there is no endpoint) inside its own `layerTest`, and the harness
1284
+ keeps serving the chain. One fake per thing that is not the chain; the chain is
1285
+ the harness's.
1286
+
1287
+ ### What the fake does and does not do
1288
+
1289
+ - **Its client supports `$extend`.** `SuiCoreFake`'s handle exposes `client`, a
1290
+ `ClientWithCoreApi` that implements `$extend`, so a derived Promise face can
1291
+ be tested exactly the way a consumer writes it — `fake.client.$extend(escrow(options))`
1292
+ — with no network.
1293
+ - **`getDynamicField` matches on `name.type` *and* `name.bcs`.** An entry
1294
+ scripted without `bcs` still matches any key of its type, which is what a test
1295
+ that only cares about the type wants; two entries of the same type on one
1296
+ parent are told apart by their bytes, so a test **can** prove which key
1297
+ encoding a lookup used.
1298
+ - **Call recording is reached through `SuiTest.calls`**, not off the fake
1299
+ handle: `yield* SuiTest.calls("getDynamicField")` gives every call in order
1300
+ with the options it was sent.
1301
+ - **A scripted `commandResults` entry may leave an array out.**
1302
+ `SuiClientTypes.CommandResult` requires both `returnValues` and
1303
+ `mutatedReferences` on the wire, and a missing one defaults to `[]` here. A
1304
+ complete entry for a `sui.view`:
1305
+
1306
+ <!-- inline -->
1307
+
1308
+ ```ts
1309
+ FakeOutcome.succeed({
1310
+ commandResults: [{ returnValues: [{ bcs: Address.serialize(owner).toBytes() }] }]
1311
+ })
1312
+ ```
1313
+
1314
+ Before the default, omitting `mutatedReferences` failed the whole `Simulation`
1315
+ decode with an issue naming a field the test never mentioned.
1316
+ - **`getTransaction` can be answered by digest.** `FakeScript.transactions`
1317
+ (and `SuiTest.recordTransaction(digest, outcome)`) answers a specific digest
1318
+ before the ordered `scriptGetTransaction` is consulted, which is what a
1319
+ `NotApplied { inputConsumed }` test needs: the rule reads
1320
+ `changedObjects[].inputVersion` off the **consuming** transaction, and
1321
+ `FakeChange.inputVersion` is how a test says which version that was.
1322
+ - **`Tx.build` always simulates.** A test that asserts a `simulateTransaction`
1323
+ call count is asserting on that, so a conversion moving onto this build has to
1324
+ move those numbers.
1325
+ - **It runs no Move code.** Execution outcomes are scripted (`FakeOutcome`), and
1326
+ what your recipe actually does on chain is localnet's business.
1327
+
1328
+ What a test file covers:
1329
+
1330
+ - the happy path of every member, through the real service;
1331
+ - every error in a member's declared union, produced by the fake and asserted
1332
+ with `Effect.flip` and `instanceof`;
1333
+ - what the extension *sent*, not only what came back (`SuiTest.calls`);
1334
+ - anything time-dependent, under `TestClock` (the program's clock) or
1335
+ `SuiTest.setClock` (the chain's);
1336
+ - the `outcome` of your errors, because that is what a script's exit code is;
1337
+ - for a face with synchronous members, that `$ready()` (or `warm`) makes them
1338
+ real — the placeholder window is the one thing types cannot catch for you.
1339
+
1340
+ ## 11. Scripts and exit codes
1341
+
1342
+ A script that uses an extension provides its layer and yields the service:
1343
+ `Script` brings `Sui` and `SuiCore`, which is everything an extension layer
1344
+ requires. `examples/extension-consumer.ts` is that script end to end.
1345
+
1346
+ Because your errors declare an `outcome`, a script that fails inside your
1347
+ extension exits with the code a wrapper can act on — 5 applied, 4 not applied,
1348
+ 3 unknown — with no handling lines anywhere.
1349
+
1350
+ `SuiError.toJson` serializes your errors too. A tag in @unconfirmed/sui-effect's own taxonomy
1351
+ encodes through the taxonomy's schema; **anything else that is a
1352
+ `Schema.TaggedError` encodes through its own**, so an extension error arrives as
1353
+ `{ _tag, escrowId, outcome }` rather than a bare `{ _tag, message }`. That is
1354
+ what makes a structured log of a failed run useful, and it is a reason to give
1355
+ every field of an error a schema rather than stuffing detail into a string.
1356
+
1357
+ Two of those deserve a second look. `UnexpectedEffects` — what
1358
+ `executed.expectCreated(type)` fails with — is **applied**, exit 5: it can only
1359
+ come from an `Executed`, so the transaction ran and gas was charged and only the
1360
+ receipt is missing; treating it as "safe to retry" would run the caller's intent
1361
+ twice. And a `Cause.TimeoutError` from an `Effect.timeout` wrapped *around* a
1362
+ submission exits 3, not 4, when the journal still holds an unresolved entry: the
1363
+ outer timeout interrupts the submission from outside and the bytes may be on the
1364
+ wire. `Script.run` prints those unresolved entries, with their base64 bytes, on
1365
+ every non-zero exit.
1366
+
1367
+ ## 12. Converting an existing facade
1368
+
1369
+ Copying the template is the greenfield path. A 14k-line facade with standalone
1370
+ functions beside it is a different job, and the order that works is this.
1371
+
1372
+ 1. **Inventory the namespaces first.** List what consumers actually call,
1373
+ 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.
1377
+ 2. **Keep the standalone functions.** An existing `Effect<A, E, Sui>` function
1378
+ that is exported and used outside the facade stays exported and keeps its
1379
+ signature. Do not make consumers hold a service to call something that never
1380
+ needed one.
1381
+ 3. **Assemble the service from those functions.** The service's members are thin:
1382
+ they close over the layer's `Sui` (`Effect.provideService(Sui, sui)` as an
1383
+ extra argument to `Effect.fn`) and call the standalone function. One
1384
+ implementation, two entry points — which is the same trick as the Promise
1385
+ face, one level down.
1386
+ 4. **Decide where the synchronous members go.** Recipe builders, id derivations,
1387
+ codecs and constants can live on the service (a namespace like `tx`) or stay
1388
+ free exports. On the service they are reachable from a Promise consumer, at
1389
+ the cost of the `$ready` / `warm` rule in section 7. As free exports they are
1390
+ simply functions and a Promise consumer imports them. Pick per member: things
1391
+ a Promise consumer composes with the rest of the surface go on the service
1392
+ and the registration gets `warm`; things only Effect code uses stay free.
1393
+ 5. **Convert the errors before the methods.** Every predecessor error maps to a
1394
+ taxonomy tag or to one of your own with an `outcome` (section 2). Doing this
1395
+ first means each method's union is already decided when you write it.
1396
+ 6. **Move one namespace at a time, with its tests.** The old facade and the new
1397
+ service can coexist behind the same package export for as long as the
1398
+ conversion takes.
1399
+
1400
+ What not to do: do not wrap the old facade in the new service. The point of the
1401
+ conversion is that every method gets a closed error union, and a wrapper around
1402
+ a `Promise`-shaped class has `unknown` in its error channel by construction.
1403
+
1404
+ ## 13. Migrating a `@misofm/effect` package
1405
+
1406
+ The predecessor library and its consumers map onto @unconfirmed/sui-effect like this. The
1407
+ conversion is mechanical except where the behaviour deliberately changed.
1408
+
1409
+ | `@misofm/effect` | @unconfirmed/sui-effect |
1410
+ |---|---|
1411
+ | `SuiClient.layer(client)` | `SuiCore.layerFromClient(client)` under `Sui.layerNoDeps`, which does the chain-id check `ready()` did by hand |
1412
+ | `yield* SuiClient` then `client.core.x(...)` | `sui.core.x(...)`, or `sui.core.use((client, signal) => ...)` when the SDK client object itself is needed. The reach-through disappears; the error mapping and the `AbortSignal` come with it |
1413
+ | `SuiGraphQL` | @unconfirmed/sui-effect's `SuiGraphQL` — the same tag for everyone, over the SDK's `SuiGraphQLClient`. @unconfirmed/sui-effect wraps no GraphQL API: you keep your queries and map failures yourself. `SuiGraphQL.layer(client)`, `layerConfig` (`SUI_GRAPHQL_URL`, `SUI_NETWORK`), `layerUnavailable` |
1414
+ | `GraphQLUnavailableError` | `GraphQLUnavailable { method, reason }`, in the taxonomy, outcome `not_applied` — what `SuiGraphQL.layerUnavailable` rejects every call with |
1415
+ | `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
+ | `ObjectNotFoundError` | `ObjectNotFound`, plus `ObjectDeleted` and `ObjectUnavailable` from the SDK's own `reason` |
1417
+ | `ObjectTypeMismatchError` | `DecodeError { objectId, expectedType, issue }` from the bridge's tag check |
1418
+ | `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
+ | `BcsDecodeError` | `DecodeError` |
1420
+ | `TransactionFailedError { digest, status }` | `ExecutionFailed { digest, reason, command, effects }` |
1421
+ | `getObjectContent` | `sui.getObject(id)` — with no schema, `content` is the raw bytes |
1422
+ | `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 |
1424
+ | `listDynamicFields` | `sui.streamDynamicFields` |
1425
+ | 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
+ | `deriveDynamicFieldID` + `getObjectOption` for existence | `sui.getDynamicFieldOption(parent, name)` — one call, `None` for absent |
1427
+ | `decodeBcs(codec, schema, bytes)` | `SuiSchema.bcs(codec, expectedType?)`, composed with a domain class through `Schema.decodeTo`, passed as `sui.getObject(id, { schema })`; for bytes you already have, `SuiSchema.decode(codec, bytes, { objectId?, expectedType?, actualType? })`. The codec must be a `BcsType` — codegen's `MoveStruct` / `MoveEnum` / `MoveTuple` are; a hand-rolled `{ parse }` is not |
1428
+ | `assertObjectType` | folded into the bridge's tag check, where a bare tag matches every instantiation — and where matching is on `address::module::name`, **not** a suffix. A call that relied on `assertObjectType` accepting a suffix (`"::escrow::Escrow"`) has to name the full tag, derived from the configured type origin |
1429
+ | `register(client)` throwing at registration | a `warm` registration surfaces a `DeploymentError`-shaped failure **synchronously, out of `register`** rather than as a rejected first call. Catch it where you register |
1430
+ | a `string` object id or address | `ObjectId.make(id)` / `SuiAddress.make(addr)` at the boundary for a literal you control, `Schema.decodeUnknownEffect(ObjectId)` for anything that came from outside. This is most of the mechanical diff: `Sui.*` takes branded ids, not `string` |
1431
+ | `TxThunk` | `Recipe = (tx) => void`. Every thunk in the SDKs is already synchronous; a **consumer's** `async (tx) => …` is not, and it hoists its `await` in front of the recipe — the read happens in the surrounding Effect, the recipe stays pure |
1432
+ | `buildTx(...thunks)` | compose recipes: `(tx) => { a(tx); b(tx) }`, then `Tx.build`. When what you need is a `Transaction` **object** to hand to something else, build it yourself: `const tx = new Transaction(); recipe(tx)` — `Tx.build` returns signed-ready bytes and needs a sender |
1433
+ | `signAndExecute` / `execThunks` | `Tx.run(recipe, { signer })`; the separate `waitForTransaction` is gone |
1434
+ | `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
+ | `ExecResult` and its extractors | `Executed` with `created(type)`, `createdWhere(predicate)`, `packagesPublished()`, `balanceChange(address, coinType)`, `expectCreated` |
1436
+ | a `register(client)` building a class of Promise methods | the service above plus `SuiExtension.fromService`, with `warm` when the surface has synchronous members |
1437
+
1438
+ Five behaviour changes to put in the conversion issues:
1439
+
1440
+ 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`.
1443
+ 3. `created(type)` compares normalized struct tags; the substring matching of
1444
+ `createdByType` / `allCreatedByType` is `createdWhere(predicate)`.
1445
+ 4. `Tx.run` replaces sign-and-execute plus wait, and a transport failure once
1446
+ bytes may have been sent is a `SubmissionUnknown` carrying them, not a retry
1447
+ loop.
1448
+ 5. Ids and addresses are branded. `ObjectId.make` at the boundary is not
1449
+ ceremony: it is the one place a malformed id is caught, instead of at a node.
1450
+ It **throws**, so it is for literals you control; everything from outside
1451
+ goes through `Schema.decodeUnknownEffect`.
1452
+ 6. `Tx.build` **always** simulates, so any test asserting a
1453
+ `simulateTransaction` call count has to move.
1454
+ 7. `NotApplied { inputConsumed }` is rare on a real network: expect
1455
+ `SubmissionUnknown` for almost every stuck submission and plan an operator or
1456
+ `reconcileAll` path.
1457
+
1458
+ **Name the target.** A conversion is against **one** sui-effect commit or tag —
1459
+ say which in the issue and in the vendored tarball's filename — because "the
1460
+ library changed under us" is otherwise indistinguishable from "the conversion
1461
+ was wrong".
1462
+
1463
+ ## 14. Review checklist
1464
+
1465
+ Reject an extension that:
1466
+
1467
+ - has a `Promise`, an `Error`, a `Cause` or an `unknown` anywhere in an
1468
+ interface or an error channel;
1469
+ - calls `SuiCore.executeTransaction`, `signAndExecuteTransaction` or
1470
+ `waitForTransaction` instead of `Tx.submit` / `Tx.run`;
1471
+ - holds a consumer's signer, or any per-call credential, in a layer;
1472
+ - builds its own SDK client instead of requiring `Sui`;
1473
+ - maintains a Promise facade by hand instead of deriving it with
1474
+ `SuiExtension.fromService`;
1475
+ - defines an error without an `outcome`, or invents an error the taxonomy
1476
+ already names;
1477
+ - leaves a requirement in a member's `R` instead of providing `Sui` inside the
1478
+ layer;
1479
+ - exposes a submission where a recipe fragment would let consumers compose;
1480
+ - re-exports an upstream package's types, or lets one reach a consumer
1481
+ undecoded;
1482
+ - has a service without `layer`, `layerConfig` and `layerTest`, or a
1483
+ `layerTest` its own tests do not use;
1484
+ - caches versioned on-chain state — an object reference, a version — in a layer;
1485
+ - runs an Effect (`Effect.runPromise`, `runSync`, a `ManagedRuntime`) anywhere
1486
+ but the derived Promise face;
1487
+ - reads `process.env` or `Date.now()` instead of `Config` and `DateTime`;
1488
+ - ships a public member whose JSDoc does not state its error union in words;
1489
+ - declares `@unconfirmed/sui-effect`, `effect` or `@mysten/sui` in `dependencies` rather than
1490
+ in `peerDependencies` **and** `devDependencies`;
1491
+ - hand-builds a `TransportError` instead of using `TransportError.fromUnknown`;
1492
+ - registers two extensions on one client with different chain ids, or mixes a
1493
+ `warm` registration with a lazy one whose chain id differs;
1494
+ - puts `Layer.orDie` over `Sui.layerNoDeps` (or any layer that can fail with
1495
+ `NetworkMismatch`) in a compatibility shim — a chain mismatch becomes a defect
1496
+ nobody can catch;
1497
+ - proves its dynamic-field filtering with a fake that has **one key type per
1498
+ parent**, which proves nothing about filtering;
1499
+ - has a README `catchTag` string that does not match the tag the error actually
1500
+ carries, prefix included;
1501
+ - leaves `tests` out of the package `tsconfig`'s `include`, so its type-level
1502
+ pins never compile;
1503
+ - calls `.make` on a branded schema with a value that came from outside;
1504
+ - promises a `ConfigError` for an empty environment variable it reads with
1505
+ `Config.option`.
1506
+
1507
+ The effect-ts skill's own checklist still applies underneath: v3 names,
1508
+ `Effect.gen` returned from a plain arrow, throwing inside an Effect, mutable
1509
+ module-level state, `run*` outside an entrypoint.
1510
+
1511
+ ## 15. Copying the template
1512
+
1513
+ <!-- from: examples/extension-template/package.json -->
1514
+
1515
+ ```json
1516
+ "peerDependencies": {
1517
+ "@mysten/bcs": "^2.1.1",
1518
+ "@mysten/sui": "^2.28",
1519
+ "effect": "4.0.0-rc.112",
1520
+ "@unconfirmed/sui-effect": "^0.1.0"
1521
+ },
1522
+ "peerDependenciesMeta": {
1523
+ "@unconfirmed/sui-effect": {
1524
+ "optional": true
1525
+ }
1526
+ },
1527
+ "devDependencies": {
1528
+ "@effect/language-service": "0.87.2",
1529
+ "@mysten/bcs": "2.1.1",
1530
+ "@mysten/sui": "2.30.0",
1531
+ "@types/bun": "1.4.2",
1532
+ "effect": "4.0.0-rc.112",
1533
+ "typescript": "5.9.3"
1534
+ }
1535
+ ```
1536
+
1537
+ `@unconfirmed/sui-effect`, `effect`, `@mysten/sui` and **`@mysten/bcs`** are peer
1538
+ dependencies, with the exact rcs pinned in `devDependencies`. `@mysten/bcs` is
1539
+ on that list because your BCS layouts are `BcsType`s from it and they cross the
1540
+ boundary into `SuiSchema.bcs`: two copies in one process is two `BcsType`
1541
+ classes, and a codec built with one is not the codec the other's `instanceof`
1542
+ recognises. Two copies of `effect` means two `Context.Service` identities and
1543
+ layers that silently do not match; two copies of `@mysten/sui` means
1544
+ `instanceof` on its error classes fails.
1545
+
1546
+ The template is shipped inside the published package, so
1547
+ `node_modules/@unconfirmed/sui-effect/examples/extension-template/` is a directory you can
1548
+ copy even when you have no checkout of this repository.
1549
+
1550
+ `examples/extension-template/README.md` has the step by step: rename the
1551
+ package, the service identifier and the registration name; drop the `paths`
1552
+ blocks that resolve `@unconfirmed/sui-effect` inside this repository; replace the package id,
1553
+ the BCS layouts and the Move targets; keep the shape.
1554
+
1555
+ ### The package has to actually build
1556
+
1557
+ `exports` points into `dist`, so something has to put a `dist` there. The
1558
+ template ships `tsconfig.build.json` (emit on, `rootDir: src`, declarations and
1559
+ maps) and a `build` script, and its `files` list is `dist` plus the README —
1560
+ which is exactly the combination that is easy to get wrong and impossible to
1561
+ notice, because `tsc --noEmit` and `bun test` both import `src/` and pass for a
1562
+ package that ships nothing at all.
1563
+
1564
+ So the template's own check does not stop at those two. `bun run check` also
1565
+ runs `scripts/check-package.ts`, which builds, packs the tarball, unpacks it
1566
+ into a throwaway `node_modules`, and imports the package the way a consumer
1567
+ will. Copy that script along with the rest: it is the only step that looks at
1568
+ what you are actually publishing.
1569
+
1570
+ The template is `version: "0.0.0"` and **not** `private`, because a package
1571
+ meant to be copied and published must not carry a flag that silently refuses to
1572
+ publish. Set your own name, version and `publishConfig.access` before you run
1573
+ `npm publish`.
1574
+
1575
+ ### TypeScript
1576
+
1577
+ @unconfirmed/sui-effect is built with TypeScript 5.9 and its emitted declarations are what a
1578
+ consumer typechecks against. **Consumers on TypeScript 7 (`tsgo`) are
1579
+ supported** — there is nothing in the shipped `.d.ts` that needs the old
1580
+ compiler — and an extension package is free to use it. The `prepare` script in
1581
+ this repository (`effect-language-service patch`) is a *library* concern: it
1582
+ patches the checker for the diagnostics we develop against, and it belongs to
1583
+ whoever builds this package, not to whoever consumes it. Do not copy it into a
1584
+ consumer.
1585
+
1586
+ ## 16. Before the first release
1587
+
1588
+ @unconfirmed/sui-effect is published as `@unconfirmed/sui-effect` on npm. While a conversion runs ahead of
1589
+ a release that has not happened yet — a new peer version, an unpublished
1590
+ change — the dependency needs a form that does not exist on the registry.
1591
+
1592
+ **Use the packed tarball.** It is the default, not the fallback:
1593
+
1594
+ ```bash
1595
+ cd /path/to/sui-effect && bun run build && npm pack
1596
+ mkdir -p vendor && cp /path/to/sui-effect/unconfirmed-sui-effect-0.1.0.tgz vendor/
1597
+ cd /path/to/your-package && bun add -d ./vendor/unconfirmed-sui-effect-0.1.0.tgz
1598
+ ```
1599
+
1600
+ It is also the only form that proves anything: an isolated consumer of the
1601
+ tarball exercises the published `files` list and the `exports` map, which a
1602
+ symlink does not.
1603
+
1604
+ **A `link:` or `bun link` to an external checkout does not dedupe the peers.**
1605
+ Module resolution follows the symlink's *real* path, so the linked checkout
1606
+ resolves `effect` and `@mysten/sui` out of its own `node_modules` while your
1607
+ package resolves them out of yours. The two copies are nominally distinct: every
1608
+ class that crosses the boundary fails to typecheck with `#private` mismatches,
1609
+ and at runtime two copies of `effect` means two `Context.Service` identities and
1610
+ layers that silently do not match. Reserve `link:` for a **real workspace
1611
+ member**, where one `node_modules` serves both.
1612
+
1613
+ **Re-pack, and diff.** A vendored tarball is a snapshot. When the library
1614
+ changes, re-pack and compare the listings (`tar -tzf new.tgz | sort` against the
1615
+ old one) before installing: a file that stopped shipping is caught there rather
1616
+ than in a consumer. Record the sui-effect commit or tag the vendor copy came
1617
+ from.
1618
+
1619
+ **Until the first publish, bun probes the registry for every peer.** It does so
1620
+ even for a peer a local dependency already satisfies, and an unpublished name
1621
+ 404s the install. The escape is
1622
+ `"peerDependenciesMeta": { "@unconfirmed/sui-effect": { "optional": true } }` in your
1623
+ `package.json` — which the template ships, because it is copied verbatim.
1624
+
1625
+ Put both halves of the swap on the release checklist:
1626
+
1627
+ 1. replace the tarball with the published range (`"@unconfirmed/sui-effect": "^0.1.0"`);
1628
+ 2. **delete the `peerDependenciesMeta` entry.** Left in, it turns a genuinely
1629
+ missing peer into a silent `undefined` at import time;
1630
+ 3. re-run the isolated-consumer check against the published package.
1631
+
1632
+ Say in the PR which form was used while the branch was in flight. A `link:` that
1633
+ reaches `main` is a build that works on one machine.
1634
+
1635
+ ## 17. What extension authors must know
1636
+
1637
+ The short list an independent verification of v0.1.0 said a downstream
1638
+ conversion has to carry. Everything here is documented somewhere above; this is
1639
+ the page to read before the conversion rather than after it.
1640
+
1641
+ - **Register every extension on a client the same way and with the same chain
1642
+ id** — all `warm: { chainId }`, or all lazy, and the same id. The base `Sui`,
1643
+ its transport and its **sender-lock map** are shared per client per effective
1644
+ chain id; disagreeing registrations get two of everything and two `Tx.run`s
1645
+ for one address stop serializing.
1646
+ - **Expect `SubmissionUnknown`, not `NotApplied { inputConsumed }`,** for almost
1647
+ every stuck submission whose PTB touched a shared object or an owned object
1648
+ older than the gas coin. `inputConsumed` needs the *consuming* transaction's
1649
+ own effects to report `inputVersion` equal to the version your bytes pinned,
1650
+ and Sui's Lamport versioning means that is usually not what happened. Plan an
1651
+ operator path or a `Tx.reconcileAll()` at startup; do not build a retry loop
1652
+ that waits for `NotApplied`.
1653
+ - **A gRPC `NOT_FOUND` during resolution arrives as a `BuildError`** naming the
1654
+ object inputs the resolver was about to look up. And devnet's simulate may not
1655
+ resolve a just-created object for a while **even after `waitForTransaction`
1656
+ returned**: visibility of a transaction is not visibility of its objects in
1657
+ the resolver path. Retry the build, do not re-read and despair.
1658
+ - **Pin `effect@4.0.0-rc.112` exactly.** rc.113 renamed `Config.nonEmptyString`,
1659
+ `Config.string` and `Config.redacted`, so neighbouring release candidates are
1660
+ not interchangeable — and two copies of `effect` in one process is a different
1661
+ and worse problem (section 16).
1662
+ - **Copy `scripts/check-package.ts`.** It resolves `@unconfirmed/sui-effect`, `effect` and
1663
+ `@mysten/*` from your own `node_modules` first, so it works outside this
1664
+ repository unchanged. `@unconfirmed/sui-effect` belongs in `devDependencies` and
1665
+ `peerDependencies`, never in `dependencies`.
1666
+ - **Use `TransportError.fromUnknown`.** Building the error by hand makes you
1667
+ guess `retryable` and throws away the status a caller needs.
1668
+ - **`sdkRefOf` is for address-owned and immutable inputs.** A shared object goes
1669
+ in with `tx.sharedObjectRef({ objectId, initialSharedVersion, mutable })`,
1670
+ reading `owner.Shared.initialSharedVersion`; a receiving object with
1671
+ `tx.receivingRef`. Passing a shared object by `objectRef` produces bytes a
1672
+ validator rejects.
1673
+ - **Under `Random.withSeed`, `SubmitConfig.nonce` is deterministic.** A test
1674
+ that builds the same transaction twice and expects two different digests has
1675
+ to provide `nonce` explicitly.
1676
+ - **`Tx.build` always simulates**, so call-count assertions on
1677
+ `simulateTransaction` move when you move onto this build.
1678
+ - **A dynamic-field key may be a primitive.** Filter `name.type` with
1679
+ `SuiSchema.matchesType`; `normalizeStructTag` throws on `u64`, `bool`,
1680
+ `address` and `vector<u8>`.
1681
+ - **`Stream.runCollect` returns a plain `Array`** in Effect v4, not a `Chunk`.
1682
+ - **`Effect.withConfigProvider` does not exist** in rc.112: provide the
1683
+ `ConfigProvider` service.
1684
+ - **`SuiError.describe` covers `GraphQLUnavailable` and `ExtensionNotReady`**,
1685
+ and `Script.run` prints them like any other tag.