@unconfirmed/sui-effect 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +34 -11
- package/CHANGELOG.md +46 -0
- package/LLMS.md +953 -301
- package/README.md +171 -9
- package/dist/domain/bcs.d.ts.map +1 -1
- package/dist/domain/bcs.js +7 -0
- package/dist/domain/bcs.js.map +1 -1
- package/dist/domain/errors.d.ts +101 -5
- package/dist/domain/errors.d.ts.map +1 -1
- package/dist/domain/errors.js +158 -8
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/executed.d.ts +69 -0
- package/dist/domain/executed.d.ts.map +1 -1
- package/dist/domain/executed.js +196 -5
- package/dist/domain/executed.js.map +1 -1
- package/dist/domain/schemas.d.ts +27 -1
- package/dist/domain/schemas.d.ts.map +1 -1
- package/dist/domain/schemas.js +20 -2
- 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/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 +69 -2
- 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/Sui.d.ts +42 -1
- package/dist/services/Sui.d.ts.map +1 -1
- package/dist/services/Sui.js +18 -4
- package/dist/services/Sui.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 +627 -11
- package/dist/services/Tx.d.ts.map +1 -1
- package/dist/services/Tx.js +187 -8
- 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 +533 -8
- package/examples/extension-template/src/Escrow.ts +1 -1
- package/package.json +1 -1
package/docs/extensions.md
CHANGED
|
@@ -198,6 +198,36 @@ nothing happened — answering `"not_applied"` would tell the documented retry
|
|
|
198
198
|
idiom to send again. The `"not_applied"` default is for @unconfirmed/sui-effect's own
|
|
199
199
|
taxonomy, not for yours.
|
|
200
200
|
|
|
201
|
+
**Tag strings are namespaced by whoever defined them, and sui-effect's are
|
|
202
|
+
not.** @unconfirmed/sui-effect's own tags are bare — `TransportError`,
|
|
203
|
+
`ObjectNotFound`, `DecodeError` — while an extension prefixes its own, so a
|
|
204
|
+
platform's tag is `partyos/PartyNotFound` or `EscrowNotFound` depending on the
|
|
205
|
+
convention that package chose. `Effect.catchTag` matches the string exactly, so
|
|
206
|
+
**copy the tag from the installed package**, never from a migration note or
|
|
207
|
+
from memory: a `catchTag("PartyNotFound")` against a package that ships
|
|
208
|
+
`partyos/PartyNotFound` compiles (the union is open at the string level in
|
|
209
|
+
neither direction you expect) or silently never fires, and a README that
|
|
210
|
+
disagrees with the class is the single most common conversion bug. When you
|
|
211
|
+
rename or re-prefix a tag, that is a breaking change and belongs in your
|
|
212
|
+
changelog with the old and the new string side by side.
|
|
213
|
+
|
|
214
|
+
**`DecodeError` carries a `kind`, and that is what to branch on.** `"type"` is
|
|
215
|
+
"this object is not of the type I asked for" — the one a read service answers
|
|
216
|
+
with a 404 or a `filter`. `"bytes"` is "the type matched and the BCS did not
|
|
217
|
+
parse", which is a layout mismatch between your package and the chain and must
|
|
218
|
+
never be swallowed. `"shape"` is a domain schema refusing an already-parsed
|
|
219
|
+
value. The `issue` string is for a human and its wording changes between
|
|
220
|
+
releases; branching on it is how a foreign-object 404 quietly starts hiding a
|
|
221
|
+
real decode bug.
|
|
222
|
+
|
|
223
|
+
**`SuiError.outcome` takes a phase.** The default (`"post-submit"`) answers
|
|
224
|
+
`"unknown"` for a tag it does not recognise, because after a submission an
|
|
225
|
+
unfamiliar error is not evidence that nothing was sent. In a `catchAll` that can
|
|
226
|
+
only be reached **before** a submission — validation, a build, a signature —
|
|
227
|
+
pass `{ phase: "pre-submit" }` and an unrecognised tag becomes `"not_applied"`,
|
|
228
|
+
which is true by construction there. `SuiError.isTaxonomy(error)` is the same
|
|
229
|
+
question one level lower.
|
|
230
|
+
|
|
201
231
|
Do not invent an error for something the taxonomy already names. A node that
|
|
202
232
|
could not be reached is a `TransportError`; bytes that did not decode are a
|
|
203
233
|
`DecodeError`; a transaction that aborted on chain is an `ExecutionFailed`; a
|
|
@@ -558,7 +588,8 @@ out of fifty is not a failed read. Two idioms, and you should pick deliberately:
|
|
|
558
588
|
`results.filter(Result.isSuccess).map((result) => result.success)`, or
|
|
559
589
|
`Result.getOrElse(result, () => fallback)` per item, or a `Map` keyed by id so
|
|
560
590
|
a caller can ask about one;
|
|
561
|
-
- **hard** — every id must be there: `sui.
|
|
591
|
+
- **hard** — every id must be there: `sui.getObjectsStrict(ids, opts)` (named
|
|
592
|
+
`getObjectsOrFail` before 0.1.2, and still reachable under that name), which
|
|
562
593
|
fails with the first item's error (`ObjectNotFound`, `ObjectDeleted`,
|
|
563
594
|
`ObjectUnavailable` or `DecodeError`) and otherwise hands back the objects in
|
|
564
595
|
the order of the ids.
|
|
@@ -718,6 +749,66 @@ An extension whose two parties cannot both sign in one process — the sponsor i
|
|
|
718
749
|
a remote service, the sender is a wallet — uses the explicit lifecycle instead:
|
|
719
750
|
`Tx.build`, `Tx.sign`, hand the bytes over, `Tx.cosign`, `Tx.submit`.
|
|
720
751
|
|
|
752
|
+
### A signer double, for tests
|
|
753
|
+
|
|
754
|
+
`Signer.fromSdkSigner` reads `toSuiAddress()` and `getKeyScheme()` **at
|
|
755
|
+
construction** and rejects a value that has neither, so a partial double is a
|
|
756
|
+
`TypeError` where it used to be `scheme: undefined` and silence. A double is
|
|
757
|
+
`Signer.remote`, which takes exactly what a signer is:
|
|
758
|
+
|
|
759
|
+
<!-- inline -->
|
|
760
|
+
|
|
761
|
+
```ts
|
|
762
|
+
const doubleSigner = (address: SuiAddress): Signer =>
|
|
763
|
+
Signer.remote({
|
|
764
|
+
address,
|
|
765
|
+
scheme: "ED25519",
|
|
766
|
+
signTransaction: () => Effect.succeed("AAAA…" as string) // any non-empty string decodes
|
|
767
|
+
})
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
For a test that actually submits, use a real keypair
|
|
771
|
+
(`Signer.fromKeypair(Ed25519Keypair.fromSecretKey(new Uint8Array(32).fill(7)))`):
|
|
772
|
+
the fake checks that the signatures cover the addresses the bytes name, and a
|
|
773
|
+
fabricated signature only passes the count check.
|
|
774
|
+
|
|
775
|
+
### Sponsored by an external service
|
|
776
|
+
|
|
777
|
+
`Tx.run`'s `sponsor` and `Tx.cosign` both want a local `Signer`. A service that
|
|
778
|
+
co-signs **and submits** on your behalf — a relayer, a sponsorship API — has
|
|
779
|
+
neither, and `Tx.submit` is not the last step either, because the service sends
|
|
780
|
+
the bytes. The supported sequence is:
|
|
781
|
+
|
|
782
|
+
<!-- inline -->
|
|
783
|
+
|
|
784
|
+
```ts
|
|
785
|
+
const built = yield* Tx.build(Tx.sponsored({ sender, gasOwner })(recipe), {
|
|
786
|
+
sender,
|
|
787
|
+
gasOwner
|
|
788
|
+
})
|
|
789
|
+
const signed = yield* Tx.sign(built, signer)
|
|
790
|
+
// The wire form: base64 bytes, and the serialized signature string the SDK
|
|
791
|
+
// produces. `signed.signatures[0]` is the sender's; the service adds its own.
|
|
792
|
+
const envelope = {
|
|
793
|
+
transactionBlockBytes: toBase64(signed.bytes),
|
|
794
|
+
signature: signed.signatures[0]!
|
|
795
|
+
}
|
|
796
|
+
const reply = yield* callTheSponsor(envelope)
|
|
797
|
+
// Two ways to end, and both are supported:
|
|
798
|
+
// the service returned an execute envelope — decode it and keep the accessors
|
|
799
|
+
const executed = yield* Executed.fromPartial(reply)
|
|
800
|
+
// or it returned only a digest — ask the chain yourself
|
|
801
|
+
const settled = yield* Tx.reconcile(signed)
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Three things to know. The digest does not change when the sponsor adds its
|
|
805
|
+
signature, so `signed.digest` is the digest to record and to reconcile by.
|
|
806
|
+
`Tx.reconcile(signed)` — passing the `Signed`, not the bare digest — is what
|
|
807
|
+
gets the evidence rules, so a service that never sent the bytes ends as
|
|
808
|
+
`NotApplied` rather than as an eternal `SubmissionUnknown`. And whatever the
|
|
809
|
+
service returns is a **reduced** envelope: see "Relay and sponsor envelopes"
|
|
810
|
+
below, and use `Executed.fromPartial` rather than `Schema.decodeUnknownSync`.
|
|
811
|
+
|
|
721
812
|
## 6. Layers
|
|
722
813
|
|
|
723
814
|
Following the house convention: `layer(opts)` for the live one, `layerConfig`
|
|
@@ -956,6 +1047,35 @@ chain. What catches it is the chain itself: `Tx.build` stamps that id on the
|
|
|
956
1047
|
transaction's expiration and a validator refuses bytes signed for another chain.
|
|
957
1048
|
Register lazily when the assertion is what you want.
|
|
958
1049
|
|
|
1050
|
+
**Do not over-invest in testing the mismatch checks.** A consumer that reads
|
|
1051
|
+
both from one deployment manifest — `network: deployment.network` on the client
|
|
1052
|
+
and `chainId: deployment.chainIdentifier` on the registration — cannot make the
|
|
1053
|
+
network check and the chain-id check fire except by supplying an inconsistent
|
|
1054
|
+
pair on purpose. One test that a wrong `chainId` throws is worth having; a
|
|
1055
|
+
matrix over the combinations is testing the manifest, not the code.
|
|
1056
|
+
|
|
1057
|
+
**In a browser, `warm` throws into your import graph.** The natural place for a
|
|
1058
|
+
registration in an SPA is module scope, and a throw there takes down the whole
|
|
1059
|
+
module — no error boundary, no console line a user can act on, just a blank
|
|
1060
|
+
page. Two patterns work: register lazily and `await client.ext.$ready()` in a
|
|
1061
|
+
boot step that has somewhere to put the failure; or keep the `warm`
|
|
1062
|
+
registration and wrap it, exporting the error instead of throwing it:
|
|
1063
|
+
|
|
1064
|
+
<!-- inline -->
|
|
1065
|
+
|
|
1066
|
+
```ts
|
|
1067
|
+
// sui-client.ts
|
|
1068
|
+
export let bootError: unknown
|
|
1069
|
+
export const client = (() => {
|
|
1070
|
+
try {
|
|
1071
|
+
return baseClient.$extend(escrow({ warm: { chainId } }))
|
|
1072
|
+
} catch (cause) {
|
|
1073
|
+
bootError = cause
|
|
1074
|
+
return baseClient.$extend(escrow({})) // lazy: every call rejects, nothing throws
|
|
1075
|
+
}
|
|
1076
|
+
})()
|
|
1077
|
+
```
|
|
1078
|
+
|
|
959
1079
|
**Thread the chain id through your registration options**, the way
|
|
960
1080
|
`src/extension.ts` and `src/Platform.ts` both do, rather than relying on the
|
|
961
1081
|
built-in table. It is what makes the face work on `devnet` and `localnet`, and
|
|
@@ -1269,7 +1389,7 @@ const feeCollector = sui.core
|
|
|
1269
1389
|
Effect.flatMap((raw) =>
|
|
1270
1390
|
decodeAddress(raw).pipe(
|
|
1271
1391
|
Effect.mapError((issue) =>
|
|
1272
|
-
new DecodeError({ expectedType: "SuiAddress", issue: issue.message })
|
|
1392
|
+
new DecodeError({ expectedType: "SuiAddress", kind: "shape", issue: issue.message })
|
|
1273
1393
|
)
|
|
1274
1394
|
)
|
|
1275
1395
|
),
|
|
@@ -1511,6 +1631,91 @@ test("a retryable transport failure re-sends the identical bytes", async () => {
|
|
|
1511
1631
|
})
|
|
1512
1632
|
```
|
|
1513
1633
|
|
|
1634
|
+
### Scripting a submit, and the traps in it
|
|
1635
|
+
|
|
1636
|
+
Four things about the fake decide whether a submit test means anything.
|
|
1637
|
+
|
|
1638
|
+
**`Tx.submit` asks `getTransaction` when it cannot get a clean answer.** A
|
|
1639
|
+
retryable transport failure, a timeout, anything that leaves the outcome open
|
|
1640
|
+
ends in `Tx.reconcile`, and `reconcile`'s first move is a `getTransaction` for
|
|
1641
|
+
the digest. So a script whose `getTransaction` is a `succeed` is saying "this
|
|
1642
|
+
transaction is on chain" — and a submit test that did not mean that gets a
|
|
1643
|
+
**success** out of a path it was trying to prove fails. Unless the test is
|
|
1644
|
+
modelling a landed transaction, script `getTransaction: [FakeOutcome.notFound()]`
|
|
1645
|
+
and let the evidence rules run.
|
|
1646
|
+
|
|
1647
|
+
**A sponsored submit needs `Tx.cosign` first.** The fake refuses a submission
|
|
1648
|
+
whose signatures do not cover the addresses the bytes name — by count, and by
|
|
1649
|
+
the addresses recovered from the signatures themselves — the way a validator
|
|
1650
|
+
does, with a gRPC `INVALID_ARGUMENT`. `Tx.submit` reports that one outright as a
|
|
1651
|
+
`TransportError { retryable: false, status: "INVALID_ARGUMENT" }` rather than
|
|
1652
|
+
reconciling it, because the node refused the request and nothing was executed.
|
|
1653
|
+
So a sponsored-flow test builds with `Tx.sponsored`, signs with the sender,
|
|
1654
|
+
**`Tx.cosign`s with the sponsor**, and only then submits; `Tx.run(recipe, {
|
|
1655
|
+
signer, sponsor })` does all of that itself. Assert it, too — the signature
|
|
1656
|
+
count is on the recorded call:
|
|
1657
|
+
|
|
1658
|
+
<!-- inline -->
|
|
1659
|
+
|
|
1660
|
+
```ts
|
|
1661
|
+
const sent = yield* SuiTest.calls("executeTransaction")
|
|
1662
|
+
expect(sent).toHaveLength(1)
|
|
1663
|
+
expect((sent[0]!.options as { signatures: ReadonlyArray<string> }).signatures)
|
|
1664
|
+
.toHaveLength(2)
|
|
1665
|
+
```
|
|
1666
|
+
|
|
1667
|
+
**`FakeOutcome.failWith` takes either reason shape.** The SDK's
|
|
1668
|
+
`SuiClientTypes.ExecutionError` is the wire shape — a top-level `message` and an
|
|
1669
|
+
`abortCode` **string** — and sui-effect's decoded `ExecutionReason` has a
|
|
1670
|
+
`bigint` `abortCode` and no `message`. Both are accepted and the second is
|
|
1671
|
+
encoded for you; anything that is neither throws where the fixture is written,
|
|
1672
|
+
naming both shapes, instead of failing a decode several calls later on an
|
|
1673
|
+
unrelated method.
|
|
1674
|
+
|
|
1675
|
+
**Which script slot drives the build's simulate.** `Tx.build` always simulates,
|
|
1676
|
+
and on the fake that simulate is the **resolver's**: it is recorded (so
|
|
1677
|
+
`SuiTest.calls("simulateTransaction")` counts it) and it is answered by
|
|
1678
|
+
`buildSimulate` when that script has entries, and otherwise by the ordered
|
|
1679
|
+
`simulate` script. So `simulate: [FakeOutcome.failWith(...)]` makes `Tx.build`
|
|
1680
|
+
and `Tx.run` fail with `SimulationFailed` the way a node would, and
|
|
1681
|
+
`buildSimulate` is the slot to use when a test needs the build's simulate and
|
|
1682
|
+
an explicit `sui.simulate` to answer differently.
|
|
1683
|
+
|
|
1684
|
+
**`layerTest` asserts the built-in chain id for `mainnet` and `testnet`.**
|
|
1685
|
+
It is `Sui.layerNoDeps`, the production layer, so a script that says
|
|
1686
|
+
`network: "mainnet"` and a `chainId` of its own fails to build with
|
|
1687
|
+
`NetworkMismatch`. Use `network: "localnet"` (the default, which asserts
|
|
1688
|
+
nothing) in fixtures, or the real bundled identifier for the network you named.
|
|
1689
|
+
|
|
1690
|
+
**The fake writes nothing to standard error, ever.** If your test output has a
|
|
1691
|
+
line in it, it came from your code or from Effect's logger, not from here.
|
|
1692
|
+
|
|
1693
|
+
### Injecting a read failure
|
|
1694
|
+
|
|
1695
|
+
`FakeScript.getObject` is a list of `FakeOutcome`s consumed one per `getObject`,
|
|
1696
|
+
for the retry and fallback paths a script of *objects* cannot express:
|
|
1697
|
+
`FakeOutcome.transportError("UNAVAILABLE")` to drive `SuiCore`'s read retry,
|
|
1698
|
+
`FakeOutcome.notFound()` for an `ObjectNotFound`, `FakeOutcome.timeoutThen` for
|
|
1699
|
+
an interruption. A `succeed` entry — and an absent or exhausted script — serves
|
|
1700
|
+
the object map as usual. `SuiTest.scriptGetObject(outcomes)` sets it mid-test.
|
|
1701
|
+
|
|
1702
|
+
`FakeScript.balances` is keyed by **owner and coin type**: an entry with an
|
|
1703
|
+
`owner` answers only for that address, one without answers for any (which is
|
|
1704
|
+
what a pre-0.1.2 script meant), and an owner with no entry gets zero, the way a
|
|
1705
|
+
node answers.
|
|
1706
|
+
|
|
1707
|
+
### The fixture package's `node_modules` is not your source
|
|
1708
|
+
|
|
1709
|
+
An isolated-consumer fixture — a directory with its own `package.json` that
|
|
1710
|
+
installs a packed tarball, the way `scripts/check-package.ts` builds one — is
|
|
1711
|
+
typechecked against **whatever tarball it last installed** the moment `test` is
|
|
1712
|
+
in the package `tsconfig`'s `include`. So a fixture left over from a previous
|
|
1713
|
+
release quietly typechecks your new code against the old library, and the
|
|
1714
|
+
checklist item "put `test` in `include`" turns into a stale pin. Either exclude
|
|
1715
|
+
the fixture directory from `include` (it has its own `tsconfig`), or re-pack and
|
|
1716
|
+
re-install it as a step of `check`, with `bun install --force` when the filename
|
|
1717
|
+
did not change.
|
|
1718
|
+
|
|
1514
1719
|
### Your own fake beside the harness
|
|
1515
1720
|
|
|
1516
1721
|
`layerExtensionTest` composes: the first argument is *your* layer, and your
|
|
@@ -1546,6 +1751,9 @@ the harness's.
|
|
|
1546
1751
|
|
|
1547
1752
|
### What the fake does and does not do
|
|
1548
1753
|
|
|
1754
|
+
- **`getObject` can be scripted to fail.** `FakeScript.getObject` /
|
|
1755
|
+
`SuiTest.scriptGetObject` inject a transport failure, a miss or a timeout into
|
|
1756
|
+
a read; the object map serves everything else.
|
|
1549
1757
|
- **Its client supports `$extend`.** `SuiCoreFake`'s handle exposes `client`, a
|
|
1550
1758
|
`ClientWithCoreApi` that implements `$extend`, so a derived Promise face can
|
|
1551
1759
|
be tested exactly the way a consumer writes it — `fake.client.$extend(escrow(options))`
|
|
@@ -1621,6 +1829,28 @@ field; there is nothing to change in your errors. That is
|
|
|
1621
1829
|
what makes a structured log of a failed run useful, and it is a reason to give
|
|
1622
1830
|
every field of an error a schema rather than stuffing detail into a string.
|
|
1623
1831
|
|
|
1832
|
+
**A wrapper error must carry what it wrapped.** `Script.exitCode` honours a
|
|
1833
|
+
declared `outcome` **before** the tag, which is what makes an extension's errors
|
|
1834
|
+
land on the right exit code — and what makes an error that wraps one and forgets
|
|
1835
|
+
to copy the `outcome` land on the wrong one. `catchAll(cause => new MyError({
|
|
1836
|
+
cause }))` around a `Tx.run` turns a charged `ExecutionFailed` (exit 5, do not
|
|
1837
|
+
retry) into an unclassified error (exit 1) or, worse, into a default
|
|
1838
|
+
`not_applied` (exit 4, "safe to retry") and the wrapper retries a transaction
|
|
1839
|
+
that already ran. Copy both fields: `outcome: SuiError.outcome(cause)` and the
|
|
1840
|
+
digest from `digestOf(cause)`, or do not wrap at all. The same applies to a CLI
|
|
1841
|
+
that catches at the command boundary and re-raises its own error type.
|
|
1842
|
+
|
|
1843
|
+
**A CLI with its own argv parser does not need `Script.run`.** `Script.run` is a
|
|
1844
|
+
whole entrypoint — it builds the layer, forks the root fiber, installs signal
|
|
1845
|
+
handlers and exits — and a commander program with twenty subcommands has all of
|
|
1846
|
+
that. What it still wants is the two things `run` does at the end:
|
|
1847
|
+
`Script.report(exit, { stderr?, journal? })` writes one diagnostic line per
|
|
1848
|
+
failure (with a `SubmissionUnknown`'s bytes) plus every unresolved journal
|
|
1849
|
+
entry, and returns the exit code. Assign it to `process.exitCode` rather than
|
|
1850
|
+
calling `process.exit`, so buffered output flushes, and pass the journal the
|
|
1851
|
+
program actually ran with — reading the default reference would look in the
|
|
1852
|
+
process-wide in-memory journal and find nothing.
|
|
1853
|
+
|
|
1624
1854
|
Two of those deserve a second look. `UnexpectedEffects` — what
|
|
1625
1855
|
`executed.expectCreated(type)` fails with — is **applied**, exit 5: it can only
|
|
1626
1856
|
come from an `Executed`, so the transaction ran and gas was charged and only the
|
|
@@ -1642,6 +1872,18 @@ functions beside it is a different job, and the order that works is this.
|
|
|
1642
1872
|
fine, the face maps it either way. Write the interface before you move any
|
|
1643
1873
|
code: it is the only artefact the conversion is reviewed against.
|
|
1644
1874
|
|
|
1875
|
+
**Grep for the shapes the library replaces, not only for names.** Two are
|
|
1876
|
+
worth a pattern each. `\.find\(.*type\??\.includes\(` followed by a
|
|
1877
|
+
`throw` — "find the created object whose type contains `::Receipt`, or blow
|
|
1878
|
+
up" — is exactly `executed.expectCreated(type)`, which compares normalized
|
|
1879
|
+
struct tags and fails with `UnexpectedEffects { digest, expected, found }`.
|
|
1880
|
+
And `@<scope>/` imports resolved against the **published `exports` map**
|
|
1881
|
+
rather than against remembered call sites: regenerate the consumer-edit
|
|
1882
|
+
table with `grep -rn "@scope/" <consumer>/src` and one row per removed
|
|
1883
|
+
subpath export, because a re-export dropped from a subpath (`/party` no
|
|
1884
|
+
longer re-exporting `TxThunk`) breaks every importer while no facade call
|
|
1885
|
+
site changed at all.
|
|
1886
|
+
|
|
1645
1887
|
**Grep for captured aliases, not only for dotted calls.** A consumer that
|
|
1646
1888
|
writes `const party = client.miso.party` and then `party.join(...)` does not
|
|
1647
1889
|
appear in a search for `client.miso.party.join`, and a namespace that looks
|
|
@@ -1687,13 +1929,13 @@ conversion is mechanical except where the behaviour deliberately changed.
|
|
|
1687
1929
|
| `GraphQLUnavailableError` | `GraphQLUnavailable { method, reason }`, in the taxonomy, outcome `not_applied` — what `SuiGraphQL.layerUnavailable` rejects every call with |
|
|
1688
1930
|
| `DeploymentError` | your own `<pkg>/DeploymentError` (the template's `EscrowUnsupportedNetwork`), a `Schema.TaggedError` declaring `outcome: "not_applied"`, failed from a `Layer.unwrap` that reads `sui.network` (section 6) |
|
|
1689
1931
|
| `ObjectNotFoundError` | `ObjectNotFound`, plus `ObjectDeleted` and `ObjectUnavailable` from the SDK's own `reason` |
|
|
1690
|
-
| `ObjectTypeMismatchError` | `DecodeError { objectId, expectedType, issue }` from the bridge's tag check |
|
|
1932
|
+
| `ObjectTypeMismatchError` | `DecodeError { objectId, expectedType, kind: "type", issue }` from the bridge's tag check. Branch on `kind`, which is `"type"` here and `"bytes"` for a BCS failure, so a consumer that used to catch a type mismatch to 404 a foreign object keeps doing exactly that and stops swallowing real decode bugs |
|
|
1691
1933
|
| `SuiRpcError { operation }` | `TransportError { method }`. For your own HTTP or GraphQL calls, `TransportError.fromUnknown(method, cause, retryable?)` classifies the status and the retryability the way `SuiCore` does — do not hand-build the three fields |
|
|
1692
1934
|
| `BcsDecodeError` | `DecodeError` |
|
|
1693
1935
|
| `TransactionFailedError { digest, status }` | `ExecutionFailed { digest, reason, command, effects }` |
|
|
1694
1936
|
| `getObjectContent` | `sui.getObject(id)` — with no schema, `content` is the raw bytes |
|
|
1695
1937
|
| `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 |
|
|
1938
|
+
| `getObjectsContent` | `sui.getObjects` — chunked, integrity-checked, a per-item `Result` instead of silently dropping errored ids; `sui.getObjectsStrict` (the deprecated `getObjectsOrFail`) when every id must be there |
|
|
1697
1939
|
| `listDynamicFields` | `sui.streamDynamicFields` |
|
|
1698
1940
|
| filtering dynamic fields by key type | filter entries on `name.type` with `SuiSchema.matchesType` (never `normalizeStructTag`, which throws on the primitive key types), then decode `name.bcs` with `SuiSchema.decode(keyCodec, entry.name.bcs)`; the entry carries both |
|
|
1699
1941
|
| `deriveDynamicFieldID` + `getObjectOption` for existence | `sui.getDynamicFieldOption(parent, name)` — one call, `None` for absent |
|
|
@@ -1707,12 +1949,21 @@ conversion is mechanical except where the behaviour deliberately changed.
|
|
|
1707
1949
|
| `ParallelTransactionExecutor` | `Tx.run` per PTB, under the sender lock. Parallel submission from one address needs distinct gas owners (`Tx.sponsored`) and is otherwise deferred: the lock is what stops two transactions picking the same gas coin |
|
|
1708
1950
|
| `ExecResult` and its extractors | `Executed` with `created(type)`, `createdWhere(predicate)`, `packagesPublished()`, `balanceChange(address, coinType)`, `expectCreated` |
|
|
1709
1951
|
| a `register(client)` building a class of Promise methods | the service above plus `SuiExtension.fromService`, with `warm` when the surface has synchronous members |
|
|
1952
|
+
| a hand-rolled idempotent submitter (persist the signed bytes, execute, wait, re-poll by digest on error, resubmit the identical bytes) | `Tx.build` → `Tx.sign` → `Tx.submit` with a durable `Journal` (`@unconfirmed/sui-effect/journal`), and `Tx.reconcileAll()` at startup. The journal write before the first send, the verbatim resubmit and the reconcile are all in `Tx.submit`; what stays yours is the domain record, which goes in `Tx.run`'s `onSigned` hook |
|
|
1953
|
+
| `client.core.getTransaction(digest)` on a transaction that may have failed | `sui.core.getTransaction` — **not** `sui.getTransaction`, which fails with `ExecutionFailed` for a `FailedTransaction` (that is the point of it). Reach for the core tier when what you need is the failed transaction's own events or effects |
|
|
1954
|
+
| a `ready()` that checks the genesis digest before anything else | `Sui.layerNoDepsWith({ chainId: deployment.chainIdentifier })`. `Sui.layerNoDeps` asserts the **built-in table's** id for the client's network, which is not the same claim as "this is the chain my deployment manifest was generated against"; `layerNoDepsPinned(chainId)` asserts nothing and makes no call, for a consumer that has already checked |
|
|
1955
|
+
| a standalone read function the predecessor exported (`getReleaseById(client, id)`) | a member on the service taking the branded id and returning decoded content. Grep the consumer for the **function name**, not for a facade call site: a removed standalone export does not appear in any `client.*` search |
|
|
1710
1956
|
|
|
1711
1957
|
Five behaviour changes to put in the conversion issues:
|
|
1712
1958
|
|
|
1713
1959
|
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
|
|
1960
|
+
silently dropped. `getObjectsStrict` is the fail-first variant.
|
|
1961
|
+
2. `balanceChange` and `gasUsedTotal` are `bigint`, not `number` — and a
|
|
1962
|
+
`bigint` **throws** in `JSON.stringify`. Anything that logs, persists or
|
|
1963
|
+
returns one over HTTP needs `value.toString()` or a replacer
|
|
1964
|
+
(`(_, v) => typeof v === "bigint" ? v.toString() : v`). Decimal strings are
|
|
1965
|
+
what the wire uses and what every schema here decodes from, so a string is
|
|
1966
|
+
the right thing to store.
|
|
1716
1967
|
3. `created(type)` compares normalized struct tags; the substring matching of
|
|
1717
1968
|
`createdByType` / `allCreatedByType` is `createdWhere(predicate)`.
|
|
1718
1969
|
4. `Tx.run` replaces sign-and-execute plus wait, and a transport failure once
|
|
@@ -1779,7 +2030,13 @@ Reject an extension that:
|
|
|
1779
2030
|
`SuiExtension.Leaf<T>` / `SuiExtension.leaf(value)`;
|
|
1780
2031
|
- calls `.make` on a branded schema with a value that came from outside;
|
|
1781
2032
|
- promises a `ConfigError` for an empty environment variable it reads with
|
|
1782
|
-
`Config.option
|
|
2033
|
+
`Config.option`;
|
|
2034
|
+
- has a test double for a signer that is not a real SDK `Signer`
|
|
2035
|
+
(`Signer.fromSdkSigner` now rejects a value with no `toSuiAddress`,
|
|
2036
|
+
`getKeyScheme` or `signTransaction`; use `Signer.remote` for a double);
|
|
2037
|
+
- branches on a `DecodeError`'s `issue` text instead of its `kind`;
|
|
2038
|
+
- typechecks its tests against an isolated-consumer fixture's installed
|
|
2039
|
+
`node_modules` (see section 10) rather than against the source under test.
|
|
1783
2040
|
|
|
1784
2041
|
The effect-ts skill's own checklist still applies underneath: v3 names,
|
|
1785
2042
|
`Effect.gen` returned from a plain arrow, throwing inside an Effect, mutable
|
|
@@ -1922,7 +2179,250 @@ Put both halves of the swap on the release checklist:
|
|
|
1922
2179
|
Say in the PR which form was used while the branch was in flight. A `link:` that
|
|
1923
2180
|
reaches `main` is a build that works on one machine.
|
|
1924
2181
|
|
|
1925
|
-
## 17.
|
|
2182
|
+
## 17. Application consumers
|
|
2183
|
+
|
|
2184
|
+
The sections above are for the package that *is* an extension. This one is for
|
|
2185
|
+
the application that consumes one — a React or Svelte SPA, a Next route, a test
|
|
2186
|
+
suite on vitest — because none of its problems are the extension's and all of
|
|
2187
|
+
them are recurring.
|
|
2188
|
+
|
|
2189
|
+
**One runtime per process, at module scope.** An Effect program at the edge of a
|
|
2190
|
+
browser app wants exactly one `ManagedRuntime`, built once and imported
|
|
2191
|
+
everywhere:
|
|
2192
|
+
|
|
2193
|
+
<!-- inline -->
|
|
2194
|
+
|
|
2195
|
+
```ts
|
|
2196
|
+
// sui-client.ts
|
|
2197
|
+
import { SuiGrpcClient } from "@mysten/sui/grpc"
|
|
2198
|
+
import { Sui, SuiCore } from "@unconfirmed/sui-effect"
|
|
2199
|
+
import { Effect, Layer, ManagedRuntime } from "effect"
|
|
2200
|
+
|
|
2201
|
+
const client = new SuiGrpcClient({ network: deployment.network, url: deployment.url })
|
|
2202
|
+
|
|
2203
|
+
// `layerNoDepsWith({ chainId })` over the client you already have: the chain id
|
|
2204
|
+
// asserted is the deployment's, not the built-in table's entry for the network.
|
|
2205
|
+
const layer = Sui.layerNoDepsWith({ chainId: deployment.chainIdentifier }).pipe(
|
|
2206
|
+
Layer.provide(SuiCore.layerFromClient(client))
|
|
2207
|
+
)
|
|
2208
|
+
|
|
2209
|
+
export const runtime = ManagedRuntime.make(layer)
|
|
2210
|
+
export const runSui = <A, E>(effect: Effect.Effect<A, E, Sui | SuiCore>): Promise<A> =>
|
|
2211
|
+
runtime.runPromise(effect)
|
|
2212
|
+
|
|
2213
|
+
// Vite / webpack HMR: dispose the old runtime, or every edit leaks one.
|
|
2214
|
+
if (import.meta.hot) import.meta.hot.dispose(() => void runtime.dispose())
|
|
2215
|
+
```
|
|
2216
|
+
|
|
2217
|
+
**A `ManagedRuntime` memoizes its layer build, including a failure.** The build
|
|
2218
|
+
here makes one `getChainIdentifier` call, and if that call fails — a flaky
|
|
2219
|
+
network on the first paint, a proxy still waking up — the runtime caches the
|
|
2220
|
+
failed build and **every** later use fails with the same stale `TransportError`,
|
|
2221
|
+
forever. Three cures, and a browser app usually wants two of them: pass
|
|
2222
|
+
`retry` (`Sui.layerNoDepsWith({ chainId, retry: Schedule.exponential("200 millis") })`)
|
|
2223
|
+
so a transient failure does not decide the runtime's life; dispose and rebuild
|
|
2224
|
+
the runtime when a use fails at the layer (it is a module-level `let`, not a
|
|
2225
|
+
`const`, in that design); or use `Sui.layerNoDepsPinned(chainId)`, which makes no
|
|
2226
|
+
call at all when the deployment manifest has already told you the chain id.
|
|
2227
|
+
|
|
2228
|
+
**`Journal`'s default is process-wide memory, and in a browser that means
|
|
2229
|
+
nothing survives.** "A journal entry written before the first execute, so a
|
|
2230
|
+
crash mid-flight leaves a record" is true of a server process and false of a
|
|
2231
|
+
tab: a refresh is a new process with an empty `Map`, and two tabs are two
|
|
2232
|
+
journals and two sender locks. What an app actually has is the
|
|
2233
|
+
`SubmissionUnknown` in its hands — it carries the signed bytes, and
|
|
2234
|
+
`SuiError.describe` prints them — so persist *that* (IndexedDB, `localStorage`,
|
|
2235
|
+
your own backend) at the moment you catch it, and reconcile it on the next boot
|
|
2236
|
+
with `Tx.reconcile(signed)`. `@unconfirmed/sui-effect/journal` over a
|
|
2237
|
+
`KeyValueStore` is the durable version of the same idea when you want the
|
|
2238
|
+
library to do it; see the Workers section for the adapter shape.
|
|
2239
|
+
|
|
2240
|
+
**Mapping failures onto UI states.** `SuiError.outcome(error)` is the axis:
|
|
2241
|
+
`"applied"` means it happened and the UI must not offer "try again",
|
|
2242
|
+
`"unknown"` means show the digest and a reconcile action, `"not_applied"` means
|
|
2243
|
+
the button can be re-enabled. Two caveats. In a `catchAll` that only wraps a
|
|
2244
|
+
build, a simulate or a signature, pass `{ phase: "pre-submit" }`, or an
|
|
2245
|
+
extension error the taxonomy does not own comes back `"unknown"` and the UI
|
|
2246
|
+
offers a reconcile for a transaction that was never built. And
|
|
2247
|
+
`SuiError.describe(error)` is safe to show in a debug panel for **any** error,
|
|
2248
|
+
including one of your own — since 0.1.2 it falls back to the tag and message
|
|
2249
|
+
rather than returning nothing.
|
|
2250
|
+
|
|
2251
|
+
**Signing with an external cosigner, from an app.** A wallet signs as the
|
|
2252
|
+
sender, a sponsorship service signs as the gas owner and submits. That is not
|
|
2253
|
+
`Tx.run`: see "Sponsored by an external service" in section 5 for the exact
|
|
2254
|
+
sequence, and note that the digest to record is the one `Tx.sign` already
|
|
2255
|
+
returned.
|
|
2256
|
+
|
|
2257
|
+
**Testing an app on vitest.** The harness does not assume `bun:test`: it is
|
|
2258
|
+
`layerTest`/`layerExtensionTest` plus `SuiTest`, all ordinary Effect values.
|
|
2259
|
+
Give the app's own `runSui` a test double built on the same layer and the app's
|
|
2260
|
+
components are testable with no network at all:
|
|
2261
|
+
|
|
2262
|
+
<!-- inline -->
|
|
2263
|
+
|
|
2264
|
+
```ts
|
|
2265
|
+
// test/sui-client.ts
|
|
2266
|
+
import { layerTest } from "@unconfirmed/sui-effect/testing"
|
|
2267
|
+
import { Effect, Layer, ManagedRuntime } from "effect"
|
|
2268
|
+
import { Journal } from "@unconfirmed/sui-effect/tx"
|
|
2269
|
+
|
|
2270
|
+
export const testRuntime = (script: Parameters<typeof layerTest>[0] = {}) => {
|
|
2271
|
+
const runtime = ManagedRuntime.make(
|
|
2272
|
+
Layer.mergeAll(layerTest(script), Journal.layerMemory)
|
|
2273
|
+
)
|
|
2274
|
+
return { runSui: runtime.runPromise.bind(runtime), dispose: () => runtime.dispose() }
|
|
2275
|
+
}
|
|
2276
|
+
```
|
|
2277
|
+
|
|
2278
|
+
Dispose it in an `afterEach`, keep `Journal.layerMemory` in the layer (the
|
|
2279
|
+
default journal is process-wide and leaks entries between tests), and script
|
|
2280
|
+
`getTransaction: [FakeOutcome.notFound()]` on anything that submits unless the
|
|
2281
|
+
test means "this landed" — section 10 has the rest.
|
|
2282
|
+
|
|
2283
|
+
**Keep Effect out of the first paint if bundle size matters.** The runtime
|
|
2284
|
+
module above is a fine dynamic `import()`: nothing in it runs until something
|
|
2285
|
+
awaits it, and a lazy `$extend` registration costs nothing at module scope.
|
|
2286
|
+
|
|
2287
|
+
## 18. Workers and Durable Objects
|
|
2288
|
+
|
|
2289
|
+
Cloudflare Workers, Durable Objects and every other isolate runtime work, with
|
|
2290
|
+
four differences that are not obvious.
|
|
2291
|
+
|
|
2292
|
+
**There is no `process`.** `Script` (and `Script.run`) is a Node entrypoint and
|
|
2293
|
+
does not belong here; build the layer directly. Configuration comes from the
|
|
2294
|
+
Worker's `env` argument, not from `process.env`, which means providing a
|
|
2295
|
+
`ConfigProvider` per request rather than relying on the default one:
|
|
2296
|
+
|
|
2297
|
+
<!-- inline -->
|
|
2298
|
+
|
|
2299
|
+
```ts
|
|
2300
|
+
const provider = ConfigProvider.fromEnvRecord(env as Record<string, string>)
|
|
2301
|
+
const program = effect.pipe(Effect.provideService(ConfigProvider.ConfigProvider, provider))
|
|
2302
|
+
```
|
|
2303
|
+
|
|
2304
|
+
**One runtime per isolate, never one per request.** `Effect.provide(effect,
|
|
2305
|
+
Sui.layerNoDeps)` inside a `fetch` handler rebuilds the layer — and its chain-id
|
|
2306
|
+
round trip — on every request. Cache a `ManagedRuntime` in module scope (a
|
|
2307
|
+
Worker isolate is reused across requests) or on the Durable Object instance, and
|
|
2308
|
+
read the "memoizes its failure" warning in section 17 (Application consumers): in an isolate that lives
|
|
2309
|
+
for hours, a cached failed build is a much longer outage than in a tab.
|
|
2310
|
+
|
|
2311
|
+
**The sender lock does not cross isolates.** `sui.withSenderLock` is a
|
|
2312
|
+
semaphore in one runtime's memory. Two isolates, two Durable Objects, two
|
|
2313
|
+
regions — two locks, and nothing stops both picking the same gas coin. Either
|
|
2314
|
+
serialize submissions for an address through one Durable Object (which is what
|
|
2315
|
+
DOs are for), or stop depending on the lock: with `tx.setGasPayment([])` there is
|
|
2316
|
+
no gas coin to equivocate on, the node picks from the address balance, and
|
|
2317
|
+
`SubmitConfig.lockSender: false` is then correct rather than reckless. A
|
|
2318
|
+
sponsored transaction built with `Tx.sponsored` already has an empty gas
|
|
2319
|
+
payment, and `Tx.build` preserves it through the resolver.
|
|
2320
|
+
|
|
2321
|
+
**Time is not wall-clock time in a DO alarm.** `Tx.submit`'s resubmit schedule
|
|
2322
|
+
and `visibilityTimeout` are Effect sleeps inside one invocation; a Durable
|
|
2323
|
+
Object that wants to retry across hours uses an alarm and calls
|
|
2324
|
+
`Tx.reconcileAll()` (or `Tx.reconcile(signed)`) when it wakes, with a durable
|
|
2325
|
+
`Journal` underneath. That is the split: sleeps for seconds, alarms plus the
|
|
2326
|
+
journal for anything longer.
|
|
2327
|
+
|
|
2328
|
+
### A `KeyValueStore` over Durable Object storage
|
|
2329
|
+
|
|
2330
|
+
`@unconfirmed/sui-effect/journal` needs a `KeyValueStore`, and Effect ships
|
|
2331
|
+
memory, filesystem, SQL and Web Storage layers — none of which exist in a DO.
|
|
2332
|
+
`KeyValueStore.makeStringOnly({ get, set, remove, clear, size })` is the whole
|
|
2333
|
+
adapter: five members over strings, and the journal uses nothing else (its
|
|
2334
|
+
entries are JSON and its unresolved index is one more key).
|
|
2335
|
+
|
|
2336
|
+
<!-- inline -->
|
|
2337
|
+
|
|
2338
|
+
```ts
|
|
2339
|
+
import { KeyValueStore } from "effect/unstable/persistence"
|
|
2340
|
+
import { Effect, Layer } from "effect"
|
|
2341
|
+
import { layerKeyValueStore } from "@unconfirmed/sui-effect/journal"
|
|
2342
|
+
|
|
2343
|
+
const durableStore = (storage: DurableObjectStorage) =>
|
|
2344
|
+
KeyValueStore.makeStringOnly({
|
|
2345
|
+
get: (key) =>
|
|
2346
|
+
Effect.map(
|
|
2347
|
+
Effect.promise(() => storage.get<string>(key)),
|
|
2348
|
+
Option.fromNullishOr
|
|
2349
|
+
),
|
|
2350
|
+
set: (key, value) => Effect.promise(() => storage.put(key, value)),
|
|
2351
|
+
remove: (key) => Effect.asVoid(Effect.promise(() => storage.delete(key))),
|
|
2352
|
+
clear: Effect.promise(() => storage.deleteAll()),
|
|
2353
|
+
size: Effect.map(Effect.promise(() => storage.list()), (map) => map.size)
|
|
2354
|
+
})
|
|
2355
|
+
|
|
2356
|
+
const journal = (storage: DurableObjectStorage) =>
|
|
2357
|
+
layerKeyValueStore({ onUnresolved: "ignore" }).pipe(
|
|
2358
|
+
Layer.provide(Layer.succeed(KeyValueStore.KeyValueStore, durableStore(storage)))
|
|
2359
|
+
)
|
|
2360
|
+
```
|
|
2361
|
+
|
|
2362
|
+
Build it once per DO instance, alongside the runtime. `onUnresolved: "fail"`
|
|
2363
|
+
refuses to build while the store still holds unsettled entries, which is the
|
|
2364
|
+
right setting for a process whose startup is allowed to demand attention and
|
|
2365
|
+
the wrong one for a DO that must answer the next request.
|
|
2366
|
+
|
|
2367
|
+
## 19. Relay and sponsor envelopes
|
|
2368
|
+
|
|
2369
|
+
`Executed` describes the SDK's own execute include set: effects, events, balance
|
|
2370
|
+
changes and the `objectTypes` join. A relay, a sponsor or any service that
|
|
2371
|
+
submitted on your behalf sends back whatever *it* asked the node for, which is
|
|
2372
|
+
usually less — `changedObjects` with an `objectId` and an `idOperation` and
|
|
2373
|
+
nothing else, no `objectTypes`, no `balanceChanges`, no checkpoint, events as
|
|
2374
|
+
JSON with no BCS.
|
|
2375
|
+
|
|
2376
|
+
`Executed.fromPartial(envelope)` decodes exactly that. What it was not told
|
|
2377
|
+
stays "not told": the input and output states are `Unknown` rather than a
|
|
2378
|
+
guessed `ObjectWrite`, versions and digests are `null`, and the accessors read
|
|
2379
|
+
`Unknown` as "the envelope did not say" so `created()` and `deleted()` still
|
|
2380
|
+
classify from the id operation alone. JSON spellings are accepted where the
|
|
2381
|
+
SDK's types are not JSON — `bcs` as base64 or a byte array, every `u64` as a
|
|
2382
|
+
number or a `bigint` as well as the decimal string.
|
|
2383
|
+
|
|
2384
|
+
Two things it cannot invent:
|
|
2385
|
+
|
|
2386
|
+
- **the types.** `created(type)`, `mutated(type)` and `expectCreated(type)`
|
|
2387
|
+
match against the `objectTypes` join, so without one they match nothing. Ask
|
|
2388
|
+
your relay for `objectTypes`; failing that, use `created()` unfiltered or
|
|
2389
|
+
`createdWhere(predicate)` and read the ids.
|
|
2390
|
+
- **the gas.** `gasUsedTotal` is `0n` for an envelope that reported no gas.
|
|
2391
|
+
That means "not reported", not "free".
|
|
2392
|
+
|
|
2393
|
+
`Executed.fromTransactionResult(result)` is the other constructor: the strict
|
|
2394
|
+
one, for an SDK `TransactionResult` read with the full include set, which is
|
|
2395
|
+
what to use when the service handed you a real execute response.
|
|
2396
|
+
|
|
2397
|
+
### `Tx.submitVia`: the journal, for a submission you do not make
|
|
2398
|
+
|
|
2399
|
+
`Tx.submit` is what writes journal entries, and a consumer that hands its bytes
|
|
2400
|
+
to a relay never calls it — so the crash window between "signed" and "the
|
|
2401
|
+
service answered" had no record at all. `Tx.submitVia` is that path:
|
|
2402
|
+
|
|
2403
|
+
<!-- inline -->
|
|
2404
|
+
|
|
2405
|
+
```ts
|
|
2406
|
+
const executed = yield* Tx.submitVia(signed, (bytes, signatures) =>
|
|
2407
|
+
postToTheRelay({ bytes: toBase64(bytes), signature: signatures[0]! }))
|
|
2408
|
+
```
|
|
2409
|
+
|
|
2410
|
+
It writes the `Signed` entry **before** calling `send`, calls `send` exactly
|
|
2411
|
+
once (a third party's submit is not known to be idempotent, and re-sending is
|
|
2412
|
+
not the library's decision), turns the reply into an `Executed` when it carries
|
|
2413
|
+
one — an SDK `TransactionResult`, a reduced envelope, or nothing at all, in
|
|
2414
|
+
which case it asks the chain by the digest it already has — and journals the
|
|
2415
|
+
terminal answer. A failure from `send` is ambiguous, so it ends in
|
|
2416
|
+
`Tx.reconcile` with the full evidence rules; an error whose instance declares
|
|
2417
|
+
`outcome: "not_applied"` is taken at its word and fails straight through
|
|
2418
|
+
without spending a reconcile, which is how a service says "I refused this and
|
|
2419
|
+
sent nothing". Declare that field on your relay-refusal error.
|
|
2420
|
+
|
|
2421
|
+
It fails with `ExecutionFailed`, `NotApplied`, `SubmissionUnknown` (carrying the
|
|
2422
|
+
bytes, with the sender's failure as its `cause`), `JournalError` from the write
|
|
2423
|
+
before the send, and your own error when it declared itself not-applied.
|
|
2424
|
+
|
|
2425
|
+
## 20. What extension authors must know
|
|
1926
2426
|
|
|
1927
2427
|
The short list an independent verification of v0.1.0 said a downstream
|
|
1928
2428
|
conversion has to carry. Everything here is documented somewhere above; this is
|
|
@@ -1996,3 +2496,28 @@ the page to read before the conversion rather than after it.
|
|
|
1996
2496
|
not, and never will, because it validates without decoding.
|
|
1997
2497
|
- **`bun install --force` after re-packing a vendored tarball** with the same
|
|
1998
2498
|
filename and version, or bun keeps the old extraction.
|
|
2499
|
+
- **`DecodeError` carries a `kind`** — `"type"`, `"bytes"`, `"shape"`. Branch on
|
|
2500
|
+
it, never on `issue`.
|
|
2501
|
+
- **Every taxonomy error has a real `.message`** since 0.1.2 (it is
|
|
2502
|
+
`SuiError.describe`), so anything that surfaces `.message` shows a line
|
|
2503
|
+
instead of an empty string, and `SuiError.describe` accepts a foreign error
|
|
2504
|
+
rather than returning `undefined` for it.
|
|
2505
|
+
- **`Tx.reconcileAll` returns a tagged union** — `{ _tag: "Executed", executed }`
|
|
2506
|
+
or `{ _tag, error }` — not a bare `Executed | error`. It returns **only what
|
|
2507
|
+
was unresolved**; `Tx.recorded(digest)` is how to ask about a settled one.
|
|
2508
|
+
- **`Tx.submit` fails outright on a gRPC `INVALID_ARGUMENT`** instead of
|
|
2509
|
+
reconciling: the node refused the request, nothing executed, and reconciling
|
|
2510
|
+
it would ask a question about a transaction that was never sent. That is the
|
|
2511
|
+
only `TransportError` that escapes `Tx.submit`.
|
|
2512
|
+
- **A sponsored submission needs both signatures before `Tx.submit`**, on the
|
|
2513
|
+
fake as on a node. `Tx.cosign`, or `Tx.run`'s `sponsor`.
|
|
2514
|
+
- **`Signer.fromSdkSigner` validates its argument** and reads `toSuiAddress()`
|
|
2515
|
+
and `getKeyScheme()` **at construction**. A test double needs all three
|
|
2516
|
+
members, or use `Signer.remote`.
|
|
2517
|
+
- **`Signer.fromConfig` takes a 32-byte hex seed** as well as a Bech32
|
|
2518
|
+
`suiprivkey`, defaulting to Ed25519.
|
|
2519
|
+
- **`Tx.run` has an `onSigned` hook** between the last signature and the first
|
|
2520
|
+
send, for a consumer's own record; `Tx.submitVia` is the same lifecycle when
|
|
2521
|
+
somebody else does the sending.
|
|
2522
|
+
- **`bigint` throws in `JSON.stringify`.** Gas, balances and versions are all
|
|
2523
|
+
`bigint`; use `.toString()` or a replacer at every JSON boundary.
|
|
@@ -213,7 +213,7 @@ const make = (
|
|
|
213
213
|
Effect.flatMap((raw) =>
|
|
214
214
|
decodeAddress(raw).pipe(
|
|
215
215
|
Effect.mapError((issue) =>
|
|
216
|
-
new DecodeError({ expectedType: "SuiAddress", issue: issue.message })
|
|
216
|
+
new DecodeError({ expectedType: "SuiAddress", kind: "shape", issue: issue.message })
|
|
217
217
|
)
|
|
218
218
|
)
|
|
219
219
|
),
|
package/package.json
CHANGED