@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.
- package/AGENTS.md +209 -0
- package/LICENSE +21 -0
- package/LLMS.md +6707 -0
- package/README.md +300 -0
- package/dist/domain/bcs.d.ts +101 -0
- package/dist/domain/bcs.d.ts.map +1 -0
- package/dist/domain/bcs.js +183 -0
- package/dist/domain/bcs.js.map +1 -0
- package/dist/domain/errors.d.ts +595 -0
- package/dist/domain/errors.d.ts.map +1 -0
- package/dist/domain/errors.js +476 -0
- package/dist/domain/errors.js.map +1 -0
- package/dist/domain/executed.d.ts +325 -0
- package/dist/domain/executed.d.ts.map +1 -0
- package/dist/domain/executed.js +253 -0
- package/dist/domain/executed.js.map +1 -0
- package/dist/domain/journal-entry.d.ts +468 -0
- package/dist/domain/journal-entry.d.ts.map +1 -0
- package/dist/domain/journal-entry.js +63 -0
- package/dist/domain/journal-entry.js.map +1 -0
- package/dist/domain/schemas.d.ts +950 -0
- package/dist/domain/schemas.d.ts.map +1 -0
- package/dist/domain/schemas.js +571 -0
- package/dist/domain/schemas.js.map +1 -0
- package/dist/domain/sui-schema.d.ts +12 -0
- package/dist/domain/sui-schema.d.ts.map +1 -0
- package/dist/domain/sui-schema.js +12 -0
- package/dist/domain/sui-schema.js.map +1 -0
- package/dist/extension.d.ts +7 -0
- package/dist/extension.d.ts.map +1 -0
- package/dist/extension.js +7 -0
- package/dist/extension.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +18 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +18 -0
- package/dist/internal.js.map +1 -0
- package/dist/journal.d.ts +51 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +18 -0
- package/dist/journal.js.map +1 -0
- package/dist/script.d.ts +7 -0
- package/dist/script.d.ts.map +1 -0
- package/dist/script.js +7 -0
- package/dist/script.js.map +1 -0
- package/dist/services/Journal.d.ts +76 -0
- package/dist/services/Journal.d.ts.map +1 -0
- package/dist/services/Journal.js +70 -0
- package/dist/services/Journal.js.map +1 -0
- package/dist/services/JournalKeyValueStore.d.ts +73 -0
- package/dist/services/JournalKeyValueStore.d.ts.map +1 -0
- package/dist/services/JournalKeyValueStore.js +155 -0
- package/dist/services/JournalKeyValueStore.js.map +1 -0
- package/dist/services/Script.d.ts +210 -0
- package/dist/services/Script.d.ts.map +1 -0
- package/dist/services/Script.js +441 -0
- package/dist/services/Script.js.map +1 -0
- package/dist/services/Signer.d.ts +132 -0
- package/dist/services/Signer.d.ts.map +1 -0
- package/dist/services/Signer.js +158 -0
- package/dist/services/Signer.js.map +1 -0
- package/dist/services/SubmitConfig.d.ts +175 -0
- package/dist/services/SubmitConfig.d.ts.map +1 -0
- package/dist/services/SubmitConfig.js +52 -0
- package/dist/services/SubmitConfig.js.map +1 -0
- package/dist/services/Sui.d.ts +295 -0
- package/dist/services/Sui.d.ts.map +1 -0
- package/dist/services/Sui.js +414 -0
- package/dist/services/Sui.js.map +1 -0
- package/dist/services/SuiCore.d.ts +227 -0
- package/dist/services/SuiCore.d.ts.map +1 -0
- package/dist/services/SuiCore.js +400 -0
- package/dist/services/SuiCore.js.map +1 -0
- package/dist/services/SuiCoreFake.d.ts +278 -0
- package/dist/services/SuiCoreFake.d.ts.map +1 -0
- package/dist/services/SuiCoreFake.js +1003 -0
- package/dist/services/SuiCoreFake.js.map +1 -0
- package/dist/services/SuiExtension.d.ts +208 -0
- package/dist/services/SuiExtension.d.ts.map +1 -0
- package/dist/services/SuiExtension.js +355 -0
- package/dist/services/SuiExtension.js.map +1 -0
- package/dist/services/SuiGraphQL.d.ts +98 -0
- package/dist/services/SuiGraphQL.d.ts.map +1 -0
- package/dist/services/SuiGraphQL.js +118 -0
- package/dist/services/SuiGraphQL.js.map +1 -0
- package/dist/services/Tx.d.ts +687 -0
- package/dist/services/Tx.d.ts.map +1 -0
- package/dist/services/Tx.js +1224 -0
- package/dist/services/Tx.js.map +1 -0
- package/dist/testing.d.ts +88 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +163 -0
- package/dist/testing.js.map +1 -0
- package/dist/tx.d.ts +24 -0
- package/dist/tx.d.ts.map +1 -0
- package/dist/tx.js +24 -0
- package/dist/tx.js.map +1 -0
- package/docs/extensions.md +1685 -0
- package/examples/extension-template/README.md +170 -0
- package/examples/extension-template/package.json +44 -0
- package/examples/extension-template/scripts/check-package.ts +147 -0
- package/examples/extension-template/src/Escrow.ts +445 -0
- package/examples/extension-template/src/Platform.ts +136 -0
- package/examples/extension-template/src/errors.ts +61 -0
- package/examples/extension-template/src/extension.ts +63 -0
- package/examples/extension-template/src/index.ts +34 -0
- package/examples/extension-template/src/schema.ts +158 -0
- package/examples/extension-template/src/upstream.ts +66 -0
- package/examples/extension-template/test/escrow.test.ts +559 -0
- package/examples/extension-template/tsconfig.build.json +26 -0
- package/examples/extension-template/tsconfig.json +35 -0
- 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.
|