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