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