@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,445 @@
1
+ /**
2
+ * The extension service.
3
+ *
4
+ * Everything the authoring guide asks for is in this one file: an interface
5
+ * whose every method returns an `Effect` with a closed error union, reads
6
+ * through `Sui`, writes through `Tx`, a recipe fragment a consumer can compose
7
+ * with other extensions, a submit-on-behalf operation that exists because that
8
+ * is this package's job, and three layers — `layer`, `layerConfig`, `layerTest`.
9
+ */
10
+ import {
11
+ Config,
12
+ Context,
13
+ Effect,
14
+ Layer,
15
+ Redacted,
16
+ Schema,
17
+ Stream
18
+ } from "effect"
19
+ import type { ChangedRef, Recipe, SuiObject, UnexpectedEffects } from "@unconfirmed/sui-effect"
20
+ import { normalizeSuiAddress } from "@mysten/sui/utils"
21
+ import {
22
+ DecodeError,
23
+ Digest,
24
+ ObjectId,
25
+ StructTag,
26
+ Sui,
27
+ SuiAddress,
28
+ SuiSchema,
29
+ TransportError
30
+ } from "@unconfirmed/sui-effect"
31
+ import type { RunError, Signer } from "@unconfirmed/sui-effect/tx"
32
+ import { Tx } from "@unconfirmed/sui-effect/tx"
33
+ import { EscrowNotFound, EscrowSettlementUnknown, EscrowUnsupportedNetwork } from "./errors.ts"
34
+ import { escrowType, EscrowContent, ESCROW_PACKAGE, receiptType } from "./schema.ts"
35
+ import type { SettlementApi } from "./upstream.ts"
36
+ import { settlementApi } from "./upstream.ts"
37
+
38
+ /** The decoded content of an escrow object, inferred from the BCS bridge. */
39
+ export type EscrowFields = ReturnType<typeof EscrowContent> extends
40
+ Schema.Codec<infer T, Uint8Array> ? T
41
+ : never
42
+
43
+ /** An escrow object: the envelope `Sui` returns plus its decoded content. */
44
+ export type EscrowObject = SuiObject<EscrowFields>
45
+
46
+ /**
47
+ * Everything `claimFor` can fail with, spelled once because it is long.
48
+ *
49
+ * `RunError` is the union `Tx.run` declares — build, preflight, signing,
50
+ * execution, reconcile and journal failures — so an extension that submits adds
51
+ * its own errors to it instead of repeating a dozen tags that will grow with
52
+ * the taxonomy.
53
+ */
54
+ export type ClaimForError =
55
+ | EscrowNotFound
56
+ | EscrowSettlementUnknown
57
+ | DecodeError
58
+ | UnexpectedEffects
59
+ | RunError
60
+
61
+ /**
62
+ * The escrow package, as an Effect service.
63
+ *
64
+ * No member returns a `Promise`, no member's error channel is `unknown`, and
65
+ * no member takes a signer from the layer: `claimFor` is handed one.
66
+ */
67
+ export interface EscrowService {
68
+ /** The package this service calls into. */
69
+ readonly packageId: string
70
+ /**
71
+ * The package the types this service decodes were **first** published in,
72
+ * which is what appears inside every Move type name. It is the same as
73
+ * `packageId` until the package is upgraded.
74
+ */
75
+ readonly typeOrigin: string
76
+ /**
77
+ * The address the package collects fees at, read through the upstream SDK.
78
+ *
79
+ * Fails with: `DecodeError` (the upstream answer was not an address),
80
+ * `TransportError`.
81
+ */
82
+ readonly feeCollector: Effect.Effect<SuiAddress, DecodeError | TransportError>
83
+ /**
84
+ * Reads one escrow object and decodes its content.
85
+ *
86
+ * Fails with: `EscrowNotFound` (no such object, or it was deleted),
87
+ * `DecodeError` (it is not an escrow), `TransportError`.
88
+ */
89
+ readonly get: (id: ObjectId) => Effect.Effect<
90
+ EscrowObject,
91
+ EscrowNotFound | DecodeError | TransportError
92
+ >
93
+ /**
94
+ * The commands that claim one escrow, as a recipe fragment.
95
+ *
96
+ * This is the composable half of the package: a consumer appends it to a
97
+ * transaction that also carries other extensions' fragments and submits once.
98
+ * Never fails; a recipe is synchronous, and `Tx.build` reports a recipe that
99
+ * throws as a `BuildError`.
100
+ */
101
+ readonly claim: (escrow: EscrowObject) => Recipe
102
+ /**
103
+ * Claims one escrow on the signer's behalf and tells the operator about it.
104
+ *
105
+ * The signer is a parameter, never a layer field. The write goes through
106
+ * `Tx.run`, so the journal, the expiration, the sender lock and reconcile all
107
+ * apply.
108
+ *
109
+ * Fails with: `EscrowNotFound`, `DecodeError`, `TransportError`,
110
+ * `BuildError`, `SimulationFailed`, `PolicyDenied`, `SigningError`,
111
+ * `ExecutionFailed`, `NotApplied`, `SubmissionUnknown`, `JournalError`,
112
+ * `UnexpectedEffects` (the claim applied but produced no receipt), and
113
+ * `EscrowSettlementUnknown` when the claim is on chain but the operator never
114
+ * confirmed it.
115
+ */
116
+ readonly claimFor: (
117
+ id: ObjectId,
118
+ opts: { readonly signer: Signer }
119
+ ) => Effect.Effect<ChangedRef, ClaimForError>
120
+ /** A namespace, which the Promise face maps recursively. */
121
+ readonly owned: {
122
+ /**
123
+ * Every escrow an address owns, paginated.
124
+ *
125
+ * Fails with: `DecodeError`, `TransportError`.
126
+ */
127
+ readonly stream: (
128
+ owner: SuiAddress
129
+ ) => Stream.Stream<EscrowObject, DecodeError | TransportError>
130
+ /**
131
+ * How many escrows an address owns.
132
+ *
133
+ * Fails with: `DecodeError`, `TransportError`.
134
+ */
135
+ readonly count: (
136
+ owner: SuiAddress
137
+ ) => Effect.Effect<number, DecodeError | TransportError>
138
+ }
139
+ }
140
+
141
+ /** What {@link Escrow.layer} needs to know. */
142
+ export interface EscrowOptions {
143
+ /** The published package id, which is what `moveCall` targets name. */
144
+ readonly packageId: string
145
+ /**
146
+ * The type origin: the package the Move **types** were first published in,
147
+ * which is what appears inside `pkg::escrow::Escrow`.
148
+ *
149
+ * Defaults to `packageId`, which is right until the package is upgraded —
150
+ * an upgrade gives the package a new id for calls and leaves every type name
151
+ * pointing at the original. Set it then, and codecs, owned-object filters and
152
+ * the receipt type keep checking the type that exists.
153
+ */
154
+ readonly typeOrigin?: string
155
+ /** The operator's settlement service. */
156
+ readonly url: string
157
+ /** The extension's own credential — never the consumer's. */
158
+ readonly apiKey: Redacted.Redacted<string>
159
+ }
160
+
161
+ /** The shape of the settlement service's answer, narrowed before it is used. */
162
+ const SettlementStatus = Schema.Struct({
163
+ status: Schema.Literals(["settled", "pending"])
164
+ })
165
+
166
+ const decodeSettlement = Schema.decodeUnknownEffect(SettlementStatus)
167
+ const decodeAddress = Schema.decodeUnknownEffect(SuiAddress)
168
+
169
+ /**
170
+ * The one mapping from a third-party rejection to this package's error union.
171
+ *
172
+ * Always `TransportError.fromUnknown`, never the constructor: building one by
173
+ * hand makes the author guess `retryable` and throws away the gRPC status, the
174
+ * HTTP status or the abort a caller needs to decide whether to try again. The
175
+ * guide's rule, applied in the guide's own template.
176
+ */
177
+ const transport = (method: string) => (cause: unknown): TransportError =>
178
+ TransportError.fromUnknown(method, cause)
179
+
180
+ const make = (
181
+ options: {
182
+ readonly packageId: string
183
+ readonly typeOrigin?: string
184
+ readonly api: SettlementApi
185
+ }
186
+ ): Effect.Effect<EscrowService, never, Sui> =>
187
+ Effect.gen(function*() {
188
+ const sui = yield* Sui
189
+ const { api, packageId } = options
190
+ // Every type-shaped value is derived here, from the configured origin, and
191
+ // never from the module-level constant: configuring a package id has to
192
+ // move the codecs with it.
193
+ const typeOrigin = options.typeOrigin ?? packageId
194
+ const content = EscrowContent(typeOrigin)
195
+ const receipt = receiptType(typeOrigin)
196
+ const ownedFilter = StructTag.make(escrowType(typeOrigin))
197
+
198
+ // `Sui` carries the `SuiCore` it was built over, so an extension reaches
199
+ // the mechanical tier — and through `use`, the SDK client object an
200
+ // upstream package wants — without adding `SuiCore` to its own
201
+ // requirements.
202
+ const feeCollector = sui.core
203
+ .use((client, signal) => api.resolveFeeCollector(client, packageId, signal))
204
+ .pipe(
205
+ Effect.catchTag(
206
+ ["ObjectNotFound", "ObjectDeleted", "ObjectUnavailable", "TransactionNotFound", "SimulationFailed"],
207
+ (error) => Effect.fail(transport("escrow.feeCollector")(error))
208
+ ),
209
+ // Upstream answered with `unknown`; it becomes a sui-effect schema
210
+ // before anything else in this package sees it. A value that does not
211
+ // decode is a `DecodeError` and stays one: it says which boundary was
212
+ // wrong, where `TransportError` would claim the node was unreachable.
213
+ Effect.flatMap((raw) =>
214
+ decodeAddress(raw).pipe(
215
+ Effect.mapError((issue) =>
216
+ new DecodeError({ expectedType: "SuiAddress", issue: issue.message })
217
+ )
218
+ )
219
+ ),
220
+ Effect.withSpan("Escrow.feeCollector")
221
+ )
222
+
223
+ const get = Effect.fn("Escrow.get")(function*(id: ObjectId) {
224
+ return yield* sui.getObject(id, { schema: content }).pipe(
225
+ Effect.catchTag(
226
+ ["ObjectNotFound", "ObjectDeleted"],
227
+ () => Effect.fail(new EscrowNotFound({ escrowId: id }))
228
+ ),
229
+ Effect.catchTag(
230
+ "ObjectUnavailable",
231
+ (error) => Effect.fail(transport("escrow.get")(error))
232
+ )
233
+ )
234
+ })
235
+
236
+ const claim = (escrow: EscrowObject): Recipe => (tx) => {
237
+ tx.moveCall({
238
+ target: `${packageId}::escrow::claim`,
239
+ arguments: [tx.object(escrow.id), tx.pure.u64(escrow.content.amount)]
240
+ })
241
+ }
242
+
243
+ const notify = Effect.fn("Escrow.notify")(function*(escrowId: ObjectId, digest: Digest) {
244
+ const unknownOutcome = (message: string) =>
245
+ new EscrowSettlementUnknown({ escrowId, digest, message })
246
+ // A pure upstream helper: `Effect.tryPromise` with a mapping function,
247
+ // and the signal forwarded so an interrupted claim cancels the request.
248
+ const response = yield* Effect.tryPromise({
249
+ try: (signal) => api.notifyClaim({ escrowId, digest }, signal),
250
+ catch: (cause) => unknownOutcome(`the settlement service failed: ${String(cause)}`)
251
+ })
252
+ const settlement = yield* decodeSettlement(response).pipe(
253
+ Effect.mapError((issue) =>
254
+ unknownOutcome(`the settlement service answered something unreadable: ${issue.message}`)
255
+ )
256
+ )
257
+ if (settlement.status !== "settled") {
258
+ return yield* unknownOutcome("the settlement service is still holding the claim")
259
+ }
260
+ })
261
+
262
+ const claimFor = Effect.fn("Escrow.claimFor")(function*(
263
+ id: ObjectId,
264
+ opts: { readonly signer: Signer }
265
+ ) {
266
+ const escrow = yield* get(id)
267
+ const executed = yield* Tx.run(claim(escrow), { signer: opts.signer })
268
+ // The transaction applied and gas was charged; what is missing is the
269
+ // receipt. That is what `UnexpectedEffects` means, and `outcome` puts it
270
+ // on "applied". Mapping it to `TransportError` would tell a wrapper the
271
+ // opposite — nothing happened, retry — about a claim that ran.
272
+ const created = yield* executed.expectCreated(receipt)
273
+ yield* notify(id, executed.digest)
274
+ return created
275
+ // `Tx.*` requires `Sui`, and the layer has one: providing it here is what
276
+ // keeps every member's requirement channel empty, which is what
277
+ // `SuiExtension.fromService` and every consumer expect.
278
+ }, Effect.provideService(Sui, sui))
279
+
280
+ const stream = (owner: SuiAddress) =>
281
+ sui.streamOwnedObjects(owner, { type: ownedFilter }).pipe(
282
+ Stream.mapEffect((object) =>
283
+ // `SuiSchema.decode` is the same decode `sui.getObject({ schema })`
284
+ // does, for the places that already have bytes. Bytes that do not
285
+ // decode are a `DecodeError` naming the object and the type — not a
286
+ // transport failure, which is what a node that could not be reached
287
+ // is.
288
+ SuiSchema.decode(content, object.content, {
289
+ objectId: object.id,
290
+ // The type the object actually has. Give it and `SuiSchema.decode`
291
+ // runs the same tag check `getObject` does, under the same rule: a
292
+ // bare expected tag matches every instantiation of it, a
293
+ // parameterized one is compared in full.
294
+ actualType: object.type
295
+ }).pipe(Effect.map((content): EscrowObject => ({ ...object, content })))
296
+ )
297
+ )
298
+
299
+ return {
300
+ packageId,
301
+ typeOrigin,
302
+ feeCollector,
303
+ get,
304
+ claim,
305
+ claimFor,
306
+ owned: {
307
+ stream,
308
+ count: (owner: SuiAddress) => Stream.runCount(stream(owner))
309
+ }
310
+ }
311
+ })
312
+
313
+ /**
314
+ * What this release knows about a network: the package it was published to,
315
+ * and the operator that settles for it.
316
+ */
317
+ export interface EscrowDeployment {
318
+ readonly packageId: string
319
+ readonly url: string
320
+ }
321
+
322
+ /**
323
+ * The deployments this release bundles.
324
+ *
325
+ * Every extension over a Move package has one of these, because a package id is
326
+ * per network and a consumer should not have to carry a table of them. Replace
327
+ * the ids with yours.
328
+ */
329
+ export const DEPLOYMENTS: Readonly<Record<string, EscrowDeployment>> = {
330
+ testnet: { packageId: ESCROW_PACKAGE, url: "https://settlement.testnet.example" },
331
+ mainnet: { packageId: ESCROW_PACKAGE, url: "https://settlement.example" }
332
+ }
333
+
334
+ /** The in-memory settlement service `layerTest` runs against. */
335
+ const fakeApi = (settled: boolean): SettlementApi => ({
336
+ notifyClaim: async () => ({ status: settled ? "settled" : "pending" }),
337
+ // `SuiAddress.make` validates, it does not normalize: `"0x1"` is not a
338
+ // 32-byte address and `make` throws, which the `use` boundary then reports as
339
+ // a `TransportError` from a fake that never touched a network. Normalize
340
+ // first — or write the padded form out — whenever a literal address becomes a
341
+ // branded one.
342
+ resolveFeeCollector: async () => SuiAddress.make(normalizeSuiAddress("0x1"))
343
+ })
344
+
345
+ /**
346
+ * The escrow extension.
347
+ *
348
+ * The identifier is `"<package>/<Name>"` and never changes after publication:
349
+ * it is the runtime key every copy of this module agrees on.
350
+ */
351
+ export class Escrow extends Context.Service<Escrow, EscrowService>()(
352
+ "example-extension/Escrow"
353
+ ) {
354
+ /**
355
+ * The live layer. It requires `Sui` and nothing else — it never builds a
356
+ * client of its own — which is exactly what `SuiExtension.fromService` can
357
+ * satisfy from the client `$extend` was called on. Never fails.
358
+ */
359
+ static readonly layer = (options: EscrowOptions): Layer.Layer<Escrow, never, Sui> =>
360
+ Layer.effect(
361
+ Escrow,
362
+ make({
363
+ packageId: options.packageId,
364
+ ...(options.typeOrigin === undefined ? {} : { typeOrigin: options.typeOrigin }),
365
+ api: settlementApi({ url: options.url, apiKey: Redacted.value(options.apiKey) })
366
+ })
367
+ )
368
+
369
+ /**
370
+ * The same layer from the environment: `ESCROW_PACKAGE_ID`, `ESCROW_URL` and
371
+ * `ESCROW_API_KEY`, which is `Config.redacted` because it is a secret.
372
+ *
373
+ * **The package id is read through its schema, not as a string.**
374
+ * `Config.schema(ObjectId, ...)` is the typed deployment path: an override
375
+ * that is not a 32-byte object id fails here, with the variable's name, at
376
+ * the moment the layer is built — instead of being carried into every
377
+ * `moveCall` target and every codec and surfacing three calls later as a Move
378
+ * abort nobody can trace back to an environment variable. Read every
379
+ * deployment value that has a branded schema this way.
380
+ *
381
+ * `Config.option` would be the wrong tool for any of these: it turns an
382
+ * **empty** variable into "unset", so `ESCROW_URL=""` would silently take a
383
+ * default rather than fail.
384
+ *
385
+ * Fails with: `ConfigError`.
386
+ */
387
+ static readonly layerConfig: Layer.Layer<Escrow, Config.ConfigError, Sui> = Layer.unwrap(
388
+ Effect.gen(function*() {
389
+ const options = yield* Config.all({
390
+ packageId: Config.schema(ObjectId, "PACKAGE_ID").pipe(
391
+ Config.withDefault(ObjectId.make(ESCROW_PACKAGE))
392
+ ),
393
+ url: Config.nonEmptyString("URL"),
394
+ apiKey: Config.redacted("API_KEY")
395
+ }).pipe(Config.nested("ESCROW"))
396
+ return Escrow.layer(options)
397
+ })
398
+ )
399
+
400
+ /**
401
+ * The layer for whatever network the client is already on, from the table
402
+ * this release bundles.
403
+ *
404
+ * This is the shape every extension over a Move package wants: the consumer
405
+ * has already chosen a network by building a client, and the package id
406
+ * follows from it. `Layer.unwrap` is what lets the layer *read* `Sui` before
407
+ * deciding which layer to be, and the network that has no entry is a typed
408
+ * failure rather than an `undefined` that surfaces as a Move abort three
409
+ * calls later.
410
+ *
411
+ * `layerConfig` still earns its place beside this one when configuration
412
+ * carries something the table cannot: the operator URL of a private
413
+ * deployment, a credential, a package id under test. When the only
414
+ * configuration *is* the package id, this layer is the one to ship and
415
+ * `layerConfig` is the override.
416
+ *
417
+ * Fails with: `EscrowUnsupportedNetwork`.
418
+ */
419
+ static readonly layerBundled = (
420
+ options: { readonly apiKey: Redacted.Redacted<string> }
421
+ ): Layer.Layer<Escrow, EscrowUnsupportedNetwork, Sui> =>
422
+ Layer.unwrap(
423
+ Effect.gen(function*() {
424
+ const sui = yield* Sui
425
+ const deployment = DEPLOYMENTS[sui.network]
426
+ if (deployment === undefined) {
427
+ return yield* new EscrowUnsupportedNetwork({ network: sui.network })
428
+ }
429
+ return Escrow.layer({ ...deployment, apiKey: options.apiKey })
430
+ })
431
+ )
432
+
433
+ /**
434
+ * The test layer: the real service over an in-memory settlement service, so
435
+ * an extension test drives the production code path and never opens a socket.
436
+ * Compose it with `layerExtensionTest` from `@unconfirmed/sui-effect/testing`. Never fails.
437
+ */
438
+ static readonly layerTest = (
439
+ state: { readonly settled?: boolean } = {}
440
+ ): Layer.Layer<Escrow, never, Sui> =>
441
+ Layer.effect(
442
+ Escrow,
443
+ make({ packageId: ESCROW_PACKAGE, api: fakeApi(state.settled ?? true) })
444
+ )
445
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Composing extensions: one package that builds on another.
3
+ *
4
+ * A platform SDK is rarely one Move package. It is a service per package plus a
5
+ * service on top that consumers actually hold, and the top one exposes the
6
+ * others as namespaces (`client.platform.escrow.get(id)`) rather than making a
7
+ * consumer register three extensions and remember which is which.
8
+ *
9
+ * Two rules make that work, and both are visible below.
10
+ */
11
+ import { Context, Effect, Layer } from "effect"
12
+ import type { ChangedRef, ObjectId, Sui } from "@unconfirmed/sui-effect"
13
+ import { SuiExtension } from "@unconfirmed/sui-effect/extension"
14
+ import type { Signer } from "@unconfirmed/sui-effect/tx"
15
+ import { Escrow } from "./Escrow.ts"
16
+ import type { ClaimForError, EscrowOptions, EscrowService } from "./Escrow.ts"
17
+
18
+ /** What a consumer of the platform holds. */
19
+ export interface PlatformService {
20
+ /**
21
+ * The escrow package's whole surface, as a namespace.
22
+ *
23
+ * It is the dependency service's own object, unchanged: no wrapper methods to
24
+ * keep in step, and the Promise face maps it recursively, so
25
+ * `client.platform.escrow.get(id)` works for a Promise consumer exactly as
26
+ * `platform.escrow.get(id)` does for an Effect one.
27
+ */
28
+ readonly escrow: EscrowService
29
+ /**
30
+ * One operation that spans the packages this platform composes.
31
+ *
32
+ * The error union is the composition's: this package's own errors plus
33
+ * whatever the packages underneath declare. Nothing is swallowed and nothing
34
+ * is widened.
35
+ *
36
+ * Fails with: `EscrowNotFound`, `EscrowSettlementUnknown`, `DecodeError`,
37
+ * `UnexpectedEffects`, and everything `Tx.run` declares.
38
+ */
39
+ readonly claimEverything: (
40
+ ids: ReadonlyArray<ObjectId>,
41
+ opts: { readonly signer: Signer }
42
+ ) => Effect.Effect<ReadonlyArray<ChangedRef>, ClaimForError>
43
+ }
44
+
45
+ /** What the platform needs to build the packages it composes. */
46
+ export interface PlatformOptions extends EscrowOptions {}
47
+
48
+ /** What a `$extend` registration of the platform needs beyond its layer. */
49
+ export interface PlatformRegistrationOptions extends PlatformOptions {
50
+ /**
51
+ * The chain identifier the node must be on. Required on `devnet`, `localnet`
52
+ * and any custom network, and **the same id every other registration on this
53
+ * client is given**: the base `Sui` and its sender-lock map are shared per
54
+ * client per chain id.
55
+ */
56
+ readonly chainId?: string
57
+ }
58
+
59
+ const make: Effect.Effect<PlatformService, never, Escrow> = Effect.gen(function*() {
60
+ // The dependency is yielded, not constructed: the layer below provides it.
61
+ const escrow = yield* Escrow
62
+ return {
63
+ escrow,
64
+ claimEverything: Effect.fn("Platform.claimEverything")(function*(
65
+ ids: ReadonlyArray<ObjectId>,
66
+ opts: { readonly signer: Signer }
67
+ ) {
68
+ const claimed: Array<ChangedRef> = []
69
+ for (const id of ids) claimed.push(yield* escrow.claimFor(id, opts))
70
+ return claimed
71
+ })
72
+ }
73
+ })
74
+
75
+ /**
76
+ * The platform service.
77
+ *
78
+ * The identifier is this package's, not the dependency's: two services in one
79
+ * package is exactly the case the "one service per package" rule allows for,
80
+ * because the second one is the composition.
81
+ */
82
+ export class Platform extends Context.Service<Platform, PlatformService>()(
83
+ "example-extension/Platform"
84
+ ) {
85
+ /**
86
+ * The live layer.
87
+ *
88
+ * **`Layer.provide(Escrow.layer(...))` is the point.** `Platform`'s own
89
+ * construction requires `Escrow`; providing it here means the layer this
90
+ * returns requires only `Sui`, which is the bound
91
+ * `SuiExtension.fromService` can satisfy from the consumer's client. An
92
+ * extension's own dependencies are provided inside its layer — the consumer
93
+ * never learns they exist.
94
+ *
95
+ * Never fails.
96
+ */
97
+ static readonly layer = (options: PlatformOptions): Layer.Layer<Platform, never, Sui> =>
98
+ Layer.effect(Platform, make).pipe(Layer.provide(Escrow.layer(options)))
99
+
100
+ /**
101
+ * The same composition over the dependency's test layer, which is the real
102
+ * `Escrow` over its in-memory settlement service. Never fails.
103
+ */
104
+ static readonly layerTest = (
105
+ state: { readonly settled?: boolean } = {}
106
+ ): Layer.Layer<Platform, never, Sui> =>
107
+ Layer.effect(Platform, make).pipe(Layer.provide(Escrow.layerTest(state)))
108
+ }
109
+
110
+ /**
111
+ * The registration a Promise consumer passes to `client.$extend(...)`.
112
+ *
113
+ * `warm` is given because the composed surface has synchronous members — the
114
+ * escrow package's `packageId` and its `claim` recipe fragment — and a consumer
115
+ * that reads one the moment it registers should get the value, not a
116
+ * placeholder. Neither layer touches the network at build, which is what `warm`
117
+ * requires.
118
+ *
119
+ * `options.chainId` is threaded through rather than left to the built-in table,
120
+ * so this works on `devnet` and `localnet` too — and so it matches what
121
+ * `escrow(...)` in `extension.ts` is given. Two registrations on one client
122
+ * that name the same chain id share one `Sui`, one transport and one
123
+ * sender-lock map; two that disagree share nothing.
124
+ *
125
+ * ```ts
126
+ * const client = new SuiGrpcClient({ network: "testnet", baseUrl }).$extend(platform(options))
127
+ * const escrow = await client.platform.escrow.get(id)
128
+ * const recipe = client.platform.escrow.claim(escrow)
129
+ * ```
130
+ */
131
+ export const platform = (options: PlatformRegistrationOptions) =>
132
+ SuiExtension.fromService(Platform, {
133
+ name: "platform",
134
+ layer: Platform.layer(options),
135
+ warm: options.chainId === undefined ? {} : { chainId: options.chainId }
136
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The extension's own failures.
3
+ *
4
+ * Two rules from the authoring guide are visible here. Every error is a
5
+ * `Schema.TaggedError`, so it serializes into a log line, an RPC response or a
6
+ * journal entry the same way sui-effect's own errors do. And every one of them
7
+ * declares an `outcome`, which is the axis a wrapper script acts on:
8
+ * `SuiError.outcome` and `Script.exitCode` read that field, and an extension
9
+ * error that does not declare one is "unclassified" and exits 1.
10
+ *
11
+ * The tags are prefixed with the package name because `EscrowNotFound` is a
12
+ * name two packages could plausibly both want.
13
+ */
14
+ import { Schema } from "effect"
15
+ import { Digest, type Outcome, ObjectId } from "@unconfirmed/sui-effect"
16
+
17
+ /**
18
+ * No escrow object with this id, or it is not an escrow at all.
19
+ *
20
+ * Nothing was submitted, so the outcome is `not_applied` and a wrapper may
21
+ * retry with a different id.
22
+ */
23
+ export class EscrowNotFound extends Schema.TaggedError<EscrowNotFound>()(
24
+ "escrow/EscrowNotFound",
25
+ { escrowId: ObjectId }
26
+ ) {
27
+ readonly outcome: Outcome = "not_applied"
28
+ }
29
+
30
+ /**
31
+ * The claim is on chain, but the operator's settlement service never confirmed
32
+ * it, so the escrow's off-chain state and its on-chain state may disagree.
33
+ *
34
+ * This is the case the `outcome` field exists for: the transaction applied, the
35
+ * operation as a whole did not finish, and the only safe next step is to
36
+ * reconcile rather than to retry. A script that fails with this exits 3.
37
+ */
38
+ export class EscrowSettlementUnknown extends Schema.TaggedError<EscrowSettlementUnknown>()(
39
+ "escrow/EscrowSettlementUnknown",
40
+ { escrowId: ObjectId, digest: Digest, message: Schema.String }
41
+ ) {
42
+ readonly outcome: Outcome = "unknown"
43
+ }
44
+
45
+ /**
46
+ * This release bundles no deployment for the network the client is on.
47
+ *
48
+ * The typed failure of `Escrow.layerBundled`: a layer that picks its package id
49
+ * from `sui.network` has exactly one way to fail, and a caller that can run on
50
+ * an unknown network wants to see it in the type rather than in a log line.
51
+ *
52
+ * Nothing was submitted — nothing was even built — so the outcome is
53
+ * `not_applied`. A predecessor library's `DeploymentError` becomes this: your
54
+ * own tag, prefixed with your package name, declaring its outcome.
55
+ */
56
+ export class EscrowUnsupportedNetwork extends Schema.TaggedError<EscrowUnsupportedNetwork>()(
57
+ "escrow/EscrowUnsupportedNetwork",
58
+ { network: Schema.String }
59
+ ) {
60
+ readonly outcome: Outcome = "not_applied"
61
+ }