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,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extension's tests: the real service, over the real `Sui`, over the
|
|
3
|
+
* in-memory `SuiCore`. No network, no mocks of our own — everything the test
|
|
4
|
+
* needs comes from `sui-effect/testing`.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "bun:test"
|
|
7
|
+
import { bcs } from "@mysten/sui/bcs"
|
|
8
|
+
import type { SuiClientTypes } from "@mysten/sui/client"
|
|
9
|
+
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"
|
|
10
|
+
import { Transaction, TransactionDataBuilder } from "@mysten/sui/transactions"
|
|
11
|
+
import {
|
|
12
|
+
Cause,
|
|
13
|
+
ConfigProvider,
|
|
14
|
+
DateTime,
|
|
15
|
+
Effect,
|
|
16
|
+
Exit,
|
|
17
|
+
Fiber,
|
|
18
|
+
Layer,
|
|
19
|
+
Option,
|
|
20
|
+
Redacted,
|
|
21
|
+
Schema,
|
|
22
|
+
Stream
|
|
23
|
+
} from "effect"
|
|
24
|
+
import { TestClock } from "effect/testing"
|
|
25
|
+
import type { Sui, SuiCore } from "sui-effect"
|
|
26
|
+
import { KNOWN_CHAIN_IDS, ObjectId, SuiAddress, SuiSchema } from "sui-effect"
|
|
27
|
+
import { FakeOutcome, layerExtensionTest, layerTest, SuiCoreFake, SuiTest } from "sui-effect/testing"
|
|
28
|
+
import { Journal, Signer } from "sui-effect/tx"
|
|
29
|
+
import { DEPLOYMENTS, Escrow } from "../src/Escrow.ts"
|
|
30
|
+
import { escrow as escrowRegistration } from "../src/extension.ts"
|
|
31
|
+
import { EscrowNotFound, EscrowSettlementUnknown, EscrowUnsupportedNetwork } from "../src/errors.ts"
|
|
32
|
+
import { Platform, platform as platformRegistration } from "../src/Platform.ts"
|
|
33
|
+
import { ESCROW_PACKAGE, receiptType, Settlement, SettlementContent } from "../src/schema.ts"
|
|
34
|
+
|
|
35
|
+
/** The type constants under the package this test's fixtures use. */
|
|
36
|
+
const RECEIPT_TYPE = receiptType(ESCROW_PACKAGE)
|
|
37
|
+
const SETTLEMENT = SettlementContent(ESCROW_PACKAGE)
|
|
38
|
+
|
|
39
|
+
const padded = (suffix: string) => `0x${"0".repeat(64 - suffix.length)}${suffix}`
|
|
40
|
+
const ESCROW_ID = ObjectId.make(padded("e5c0"))
|
|
41
|
+
const RECEIPT_ID = padded("7ece17")
|
|
42
|
+
const MISSING_ID = ObjectId.make(padded("111"))
|
|
43
|
+
|
|
44
|
+
const keypair = Ed25519Keypair.fromSecretKey(new Uint8Array(32).fill(7))
|
|
45
|
+
const signer = Signer.fromKeypair(keypair)
|
|
46
|
+
const SENDER = signer.address
|
|
47
|
+
const owner: SuiClientTypes.ObjectOwner = { $kind: "AddressOwner", AddressOwner: SENDER }
|
|
48
|
+
|
|
49
|
+
const EscrowBcs = bcs.struct("Escrow", {
|
|
50
|
+
id: bcs.Address,
|
|
51
|
+
owner: bcs.Address,
|
|
52
|
+
amount: bcs.u64()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const escrowObject = (amount: string) => ({
|
|
56
|
+
objectId: ESCROW_ID,
|
|
57
|
+
type: `${ESCROW_PACKAGE}::escrow::Escrow`,
|
|
58
|
+
version: 3n,
|
|
59
|
+
owner,
|
|
60
|
+
content: EscrowBcs.serialize({ id: ESCROW_ID, owner: SENDER, amount }).toBytes()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/** Everything the fake serves: one escrow, one gas coin, one scripted execution. */
|
|
64
|
+
const script = {
|
|
65
|
+
objects: [escrowObject("5")],
|
|
66
|
+
coins: [
|
|
67
|
+
{
|
|
68
|
+
objectId: padded("c01"),
|
|
69
|
+
version: "2",
|
|
70
|
+
digest: "11111111111111111111111111111111",
|
|
71
|
+
type: `0x2::coin::Coin<0x2::sui::SUI>`,
|
|
72
|
+
balance: "1000000000",
|
|
73
|
+
owner,
|
|
74
|
+
previousTransaction: null
|
|
75
|
+
} as unknown as SuiClientTypes.Coin
|
|
76
|
+
],
|
|
77
|
+
execute: [
|
|
78
|
+
FakeOutcome.succeed({
|
|
79
|
+
created: [{ objectId: RECEIPT_ID, type: RECEIPT_TYPE, version: 4n, owner }],
|
|
80
|
+
mutated: [{ objectId: ESCROW_ID, type: `${ESCROW_PACKAGE}::escrow::Escrow`, version: 4n, owner }]
|
|
81
|
+
})
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One line of wiring: the extension's own test layer over `layerTest`, which is
|
|
87
|
+
* the real `Sui` over the in-memory `SuiCore`. `{ local: true }` gives each
|
|
88
|
+
* test a fresh fake.
|
|
89
|
+
*/
|
|
90
|
+
const provide = <A, E>(
|
|
91
|
+
effect: Effect.Effect<A, E, Escrow | Sui | SuiCore | SuiCoreFake | TestClock.TestClock>,
|
|
92
|
+
state: { readonly settled?: boolean } = {}
|
|
93
|
+
) =>
|
|
94
|
+
Effect.runPromise(
|
|
95
|
+
Effect.provide(
|
|
96
|
+
effect,
|
|
97
|
+
Layer.mergeAll(
|
|
98
|
+
layerExtensionTest(Escrow.layerTest(state), script),
|
|
99
|
+
// The program's own clock, for retries and timeouts. The chain's clock
|
|
100
|
+
// is `SuiTest.setClock`.
|
|
101
|
+
TestClock.layer(),
|
|
102
|
+
// The default journal is a process-wide memory journal, so a test that
|
|
103
|
+
// submits provides its own to stay isolated.
|
|
104
|
+
Journal.layerMemory
|
|
105
|
+
),
|
|
106
|
+
{ local: true }
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
describe("Escrow", () => {
|
|
111
|
+
test("get decodes an escrow object through the BCS bridge", async () => {
|
|
112
|
+
const escrow = await provide(Effect.flatMap(Escrow, (escrow) => escrow.get(ESCROW_ID)))
|
|
113
|
+
expect(escrow.content.amount).toBe("5")
|
|
114
|
+
expect(Number(escrow.version)).toBe(3)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("get is EscrowNotFound when the object is not there", async () => {
|
|
118
|
+
const error = await provide(
|
|
119
|
+
Effect.flatMap(Escrow, (escrow) => Effect.flip(escrow.get(MISSING_ID)))
|
|
120
|
+
)
|
|
121
|
+
expect(error).toBeInstanceOf(EscrowNotFound)
|
|
122
|
+
expect((error as EscrowNotFound).outcome).toBe("not_applied")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("get is EscrowNotFound after the object is deleted", async () => {
|
|
126
|
+
const error = await provide(
|
|
127
|
+
Effect.gen(function*() {
|
|
128
|
+
const escrow = yield* Escrow
|
|
129
|
+
yield* SuiTest.deleteObject(ESCROW_ID)
|
|
130
|
+
return yield* Effect.flip(escrow.get(ESCROW_ID))
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
expect(error).toBeInstanceOf(EscrowNotFound)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("the recipe fragment composes into a consumer's transaction", async () => {
|
|
137
|
+
const commands = await provide(
|
|
138
|
+
Effect.gen(function*() {
|
|
139
|
+
const escrow = yield* Escrow
|
|
140
|
+
const object = yield* escrow.get(ESCROW_ID)
|
|
141
|
+
const tx = new Transaction()
|
|
142
|
+
// A consumer composes fragments from several extensions and submits once.
|
|
143
|
+
escrow.claim(object)(tx)
|
|
144
|
+
escrow.claim(object)(tx)
|
|
145
|
+
return tx.getData().commands
|
|
146
|
+
})
|
|
147
|
+
)
|
|
148
|
+
expect(commands).toHaveLength(2)
|
|
149
|
+
expect(commands[0]?.$kind).toBe("MoveCall")
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test("claimFor submits once and returns the receipt", async () => {
|
|
153
|
+
const { executes, receipt } = await provide(
|
|
154
|
+
Effect.gen(function*() {
|
|
155
|
+
const escrow = yield* Escrow
|
|
156
|
+
const receipt = yield* escrow.claimFor(ESCROW_ID, { signer })
|
|
157
|
+
const executes = yield* SuiTest.calls("executeTransaction")
|
|
158
|
+
return { receipt, executes: executes.length }
|
|
159
|
+
})
|
|
160
|
+
)
|
|
161
|
+
expect(String(receipt.id)).toBe(RECEIPT_ID)
|
|
162
|
+
expect(String(receipt.type)).toBe(RECEIPT_TYPE)
|
|
163
|
+
expect(executes).toBe(1)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("an unsettled claim is EscrowSettlementUnknown, which exits 3", async () => {
|
|
167
|
+
const error = await provide(
|
|
168
|
+
Effect.flatMap(Escrow, (escrow) =>
|
|
169
|
+
Effect.flip(escrow.claimFor(ESCROW_ID, { signer }))),
|
|
170
|
+
{ settled: false }
|
|
171
|
+
)
|
|
172
|
+
expect(error).toBeInstanceOf(EscrowSettlementUnknown)
|
|
173
|
+
expect((error as EscrowSettlementUnknown).outcome).toBe("unknown")
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test("the nested namespace streams and counts owned escrows", async () => {
|
|
177
|
+
const { ids, count } = await provide(
|
|
178
|
+
Effect.gen(function*() {
|
|
179
|
+
const escrow = yield* Escrow
|
|
180
|
+
const ids = yield* Stream.runCollect(
|
|
181
|
+
Stream.map(escrow.owned.stream(SuiAddress.make(SENDER)), (object) => object.id)
|
|
182
|
+
)
|
|
183
|
+
const count = yield* escrow.owned.count(SuiAddress.make(SENDER))
|
|
184
|
+
return { ids, count }
|
|
185
|
+
})
|
|
186
|
+
)
|
|
187
|
+
expect(count).toBe(1)
|
|
188
|
+
expect(ids).toEqual([ESCROW_ID])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test("the version the fake serves is the version the extension reads", async () => {
|
|
192
|
+
const versions = await provide(
|
|
193
|
+
Effect.gen(function*() {
|
|
194
|
+
const escrow = yield* Escrow
|
|
195
|
+
const before = yield* escrow.get(ESCROW_ID)
|
|
196
|
+
yield* SuiTest.bumpVersion(ESCROW_ID)
|
|
197
|
+
const after = yield* escrow.get(ESCROW_ID)
|
|
198
|
+
return [before.version, after.version]
|
|
199
|
+
})
|
|
200
|
+
)
|
|
201
|
+
expect(versions.map(Number)).toEqual([3, 4])
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe("Escrow under the two clocks", () => {
|
|
206
|
+
test("the chain's epoch bounds the transaction the extension submits", async () => {
|
|
207
|
+
const expiration = await provide(
|
|
208
|
+
Effect.gen(function*() {
|
|
209
|
+
const escrow = yield* Escrow
|
|
210
|
+
yield* SuiTest.setClock(1_000_000_000_000n)
|
|
211
|
+
yield* escrow.claimFor(ESCROW_ID, { signer })
|
|
212
|
+
const sent = yield* SuiTest.calls("executeTransaction")
|
|
213
|
+
const options = sent[0]?.options as { readonly transaction: Uint8Array }
|
|
214
|
+
return TransactionDataBuilder.fromBytes(options.transaction).expiration
|
|
215
|
+
})
|
|
216
|
+
)
|
|
217
|
+
// Every transaction an extension submits through `Tx` is bounded to the
|
|
218
|
+
// current epoch and the next, and carries the chain identifier, with no
|
|
219
|
+
// wiring in the extension at all. The bound is epochs rather than a wall
|
|
220
|
+
// clock because no Sui network accepts a timestamp expiration yet.
|
|
221
|
+
expect(expiration?.$kind).toBe("ValidDuring")
|
|
222
|
+
if (expiration?.$kind === "ValidDuring") {
|
|
223
|
+
expect(String(expiration.ValidDuring.minEpoch)).toBe("100")
|
|
224
|
+
expect(String(expiration.ValidDuring.maxEpoch)).toBe("101")
|
|
225
|
+
expect(expiration.ValidDuring.maxTimestamp).toBeNull()
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test("a retryable transport failure re-sends the identical bytes", async () => {
|
|
230
|
+
const { attempts, bytes } = await provide(
|
|
231
|
+
Effect.gen(function*() {
|
|
232
|
+
const escrow = yield* Escrow
|
|
233
|
+
yield* SuiTest.scriptExecute([
|
|
234
|
+
FakeOutcome.transportError("UNAVAILABLE"),
|
|
235
|
+
FakeOutcome.succeed({
|
|
236
|
+
created: [{ objectId: RECEIPT_ID, type: RECEIPT_TYPE, version: 4n, owner }]
|
|
237
|
+
})
|
|
238
|
+
])
|
|
239
|
+
// The resubmit schedule sleeps, so the test drives the clock rather
|
|
240
|
+
// than waiting.
|
|
241
|
+
const fiber = yield* Effect.forkChild(escrow.claimFor(ESCROW_ID, { signer }))
|
|
242
|
+
yield* TestClock.adjust("1 minute")
|
|
243
|
+
yield* Fiber.join(fiber)
|
|
244
|
+
const sent = yield* SuiTest.calls("executeTransaction")
|
|
245
|
+
return {
|
|
246
|
+
attempts: sent.length,
|
|
247
|
+
bytes: new Set(
|
|
248
|
+
sent.map((call) =>
|
|
249
|
+
String((call.options as { readonly transaction: Uint8Array }).transaction)
|
|
250
|
+
)
|
|
251
|
+
).size
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
)
|
|
255
|
+
expect(attempts).toBe(2)
|
|
256
|
+
// The same bytes both times: `Tx.submit` never rebuilds.
|
|
257
|
+
expect(bytes).toBe(1)
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe("Settlement: a domain class over the BCS bridge", () => {
|
|
262
|
+
const SettlementBcs = bcs.struct("Settlement", {
|
|
263
|
+
escrow_id: bcs.Address,
|
|
264
|
+
settled_at_ms: bcs.u64(),
|
|
265
|
+
claimed_by: bcs.Address
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test("snake_case Move fields decode into the camelCase domain class", async () => {
|
|
269
|
+
const bytes = SettlementBcs.serialize({
|
|
270
|
+
escrow_id: ESCROW_ID,
|
|
271
|
+
settled_at_ms: "1700000000000",
|
|
272
|
+
claimed_by: SENDER
|
|
273
|
+
}).toBytes()
|
|
274
|
+
const settlement = await Effect.runPromise(SuiSchema.decode(SETTLEMENT, bytes))
|
|
275
|
+
expect(settlement).toBeInstanceOf(Settlement)
|
|
276
|
+
expect(settlement.escrowId).toBe(ESCROW_ID)
|
|
277
|
+
expect(settlement.claimedBy).toBe(SENDER)
|
|
278
|
+
expect(DateTime.toEpochMillis(settlement.settledAt)).toBe(1_700_000_000_000)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test("the encode direction is the inverse mapper", async () => {
|
|
282
|
+
const bytes = SettlementBcs.serialize({
|
|
283
|
+
escrow_id: ESCROW_ID,
|
|
284
|
+
settled_at_ms: "1700000000000",
|
|
285
|
+
claimed_by: SENDER
|
|
286
|
+
}).toBytes()
|
|
287
|
+
const settlement = await Effect.runPromise(SuiSchema.decode(SETTLEMENT, bytes))
|
|
288
|
+
const encoded = await Effect.runPromise(
|
|
289
|
+
Schema.encodeUnknownEffect(SETTLEMENT)(settlement)
|
|
290
|
+
)
|
|
291
|
+
expect(Array.from(encoded)).toEqual(Array.from(bytes))
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test("a failure inside the domain transform is still a DecodeError", async () => {
|
|
295
|
+
const bytes = SettlementBcs.serialize({
|
|
296
|
+
escrow_id: ESCROW_ID,
|
|
297
|
+
// Beyond what a `Date` can be, so the domain mapping is what fails.
|
|
298
|
+
settled_at_ms: "99999999999999999",
|
|
299
|
+
claimed_by: SENDER
|
|
300
|
+
}).toBytes()
|
|
301
|
+
const error = await Effect.runPromise(
|
|
302
|
+
Effect.flip(SuiSchema.decode(SETTLEMENT, bytes, { objectId: ESCROW_ID }))
|
|
303
|
+
)
|
|
304
|
+
expect(error._tag).toBe("DecodeError")
|
|
305
|
+
expect(error.objectId).toBe(ESCROW_ID)
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
describe("layerBundled: the deployment follows the client's network", () => {
|
|
310
|
+
const apiKey = Redacted.make("test-key")
|
|
311
|
+
|
|
312
|
+
test("picks the package id bundled for the network the client is on", async () => {
|
|
313
|
+
const packageId = await Effect.runPromise(
|
|
314
|
+
Effect.provide(
|
|
315
|
+
Effect.map(Escrow, (escrow) => escrow.packageId),
|
|
316
|
+
Layer.provide(Escrow.layerBundled({ apiKey }), layerTest({ network: "testnet", chainId: KNOWN_CHAIN_IDS["testnet"]! })),
|
|
317
|
+
{ local: true }
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
expect(packageId).toBe(DEPLOYMENTS["testnet"]!.packageId)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test("a network this release does not bundle is a typed layer failure", async () => {
|
|
324
|
+
const exit = await Effect.runPromise(
|
|
325
|
+
Effect.exit(
|
|
326
|
+
Effect.provide(
|
|
327
|
+
Effect.map(Escrow, (escrow) => escrow.packageId),
|
|
328
|
+
Layer.provide(Escrow.layerBundled({ apiKey }), layerTest({ network: "localnet" })),
|
|
329
|
+
{ local: true }
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
expect(exit._tag).toBe("Failure")
|
|
334
|
+
const error = Exit.isFailure(exit)
|
|
335
|
+
? Option.getOrUndefined(Cause.findErrorOption(exit.cause))
|
|
336
|
+
: undefined
|
|
337
|
+
expect(error).toBeInstanceOf(EscrowUnsupportedNetwork)
|
|
338
|
+
expect((error as EscrowUnsupportedNetwork).network).toBe("localnet")
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
describe("Platform: one extension composed on another", () => {
|
|
343
|
+
test("the dependency's surface is a namespace on the composition", async () => {
|
|
344
|
+
const amount = await Effect.runPromise(
|
|
345
|
+
Effect.provide(
|
|
346
|
+
Effect.flatMap(Platform, (platform) => platform.escrow.get(ESCROW_ID)),
|
|
347
|
+
Layer.mergeAll(
|
|
348
|
+
// The composition's own test layer over the harness: one fake for the
|
|
349
|
+
// chain, and the dependency's own fake for its operator service.
|
|
350
|
+
layerExtensionTest(Platform.layerTest({ settled: true }), script),
|
|
351
|
+
Journal.layerMemory
|
|
352
|
+
),
|
|
353
|
+
{ local: true }
|
|
354
|
+
).pipe(Effect.map((escrow) => escrow.content.amount))
|
|
355
|
+
)
|
|
356
|
+
expect(amount).toBe("5")
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test("an operation that spans the composed packages keeps the union honest", async () => {
|
|
360
|
+
const claimed = await Effect.runPromise(
|
|
361
|
+
Effect.provide(
|
|
362
|
+
Effect.flatMap(Platform, (platform) =>
|
|
363
|
+
platform.claimEverything([ESCROW_ID], { signer })),
|
|
364
|
+
Layer.mergeAll(
|
|
365
|
+
layerExtensionTest(Platform.layerTest({ settled: true }), script),
|
|
366
|
+
Journal.layerMemory
|
|
367
|
+
),
|
|
368
|
+
{ local: true }
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
expect(claimed).toHaveLength(1)
|
|
372
|
+
expect(claimed[0]?.id).toBe(ObjectId.make(RECEIPT_ID))
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Regression tests for the codex audit of 2026-09-11, findings 18 and 23.
|
|
378
|
+
*/
|
|
379
|
+
describe("configuring a package moves the codecs with it", () => {
|
|
380
|
+
const OTHER_PACKAGE = padded("beef")
|
|
381
|
+
|
|
382
|
+
const underOtherPackage = {
|
|
383
|
+
...script,
|
|
384
|
+
objects: [
|
|
385
|
+
{
|
|
386
|
+
...escrowObject("5"),
|
|
387
|
+
// A correctly encoded escrow, published to the *configured* package.
|
|
388
|
+
type: `${OTHER_PACKAGE}::escrow::Escrow`
|
|
389
|
+
}
|
|
390
|
+
]
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const withPackage = <A, E>(
|
|
394
|
+
effect: Effect.Effect<A, E, Escrow | Sui | SuiCore | SuiCoreFake | TestClock.TestClock>,
|
|
395
|
+
packageId: string
|
|
396
|
+
) =>
|
|
397
|
+
Effect.runPromise(
|
|
398
|
+
Effect.provide(
|
|
399
|
+
effect,
|
|
400
|
+
Layer.mergeAll(
|
|
401
|
+
layerExtensionTest(
|
|
402
|
+
Escrow.layer({
|
|
403
|
+
packageId,
|
|
404
|
+
url: "https://settlement.example",
|
|
405
|
+
apiKey: Redacted.make("key")
|
|
406
|
+
}),
|
|
407
|
+
underOtherPackage
|
|
408
|
+
),
|
|
409
|
+
TestClock.layer(),
|
|
410
|
+
Journal.layerMemory
|
|
411
|
+
),
|
|
412
|
+
{ local: true }
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
test("a valid object under the configured package decodes", async () => {
|
|
417
|
+
// Before the fix the codec and the receipt type came from the module-level
|
|
418
|
+
// constant, so configuring a package id made every read fail with
|
|
419
|
+
// `DecodeError` and every claim report a missing receipt.
|
|
420
|
+
const escrow = await withPackage(
|
|
421
|
+
Effect.flatMap(Escrow, (service) => service.get(ESCROW_ID)),
|
|
422
|
+
OTHER_PACKAGE
|
|
423
|
+
)
|
|
424
|
+
expect(escrow.content.amount).toBe("5")
|
|
425
|
+
expect(String(escrow.type)).toBe(`${OTHER_PACKAGE}::escrow::Escrow`)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
test("the service reports the type origin it was built with", async () => {
|
|
429
|
+
const origin = await withPackage(
|
|
430
|
+
Effect.map(Escrow, (service) => service.typeOrigin),
|
|
431
|
+
OTHER_PACKAGE
|
|
432
|
+
)
|
|
433
|
+
expect(origin).toBe(OTHER_PACKAGE)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test("the default package still refuses an object of another package", async () => {
|
|
437
|
+
const error = await Effect.runPromise(
|
|
438
|
+
Effect.provide(
|
|
439
|
+
Effect.flip(Effect.flatMap(Escrow, (service) => service.get(ESCROW_ID))),
|
|
440
|
+
Layer.mergeAll(
|
|
441
|
+
layerExtensionTest(Escrow.layerTest(), underOtherPackage),
|
|
442
|
+
TestClock.layer(),
|
|
443
|
+
Journal.layerMemory
|
|
444
|
+
),
|
|
445
|
+
{ local: true }
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
expect(error._tag).toBe("DecodeError")
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
describe("the test fee collector", () => {
|
|
453
|
+
test("resolves to a normalized address instead of failing", async () => {
|
|
454
|
+
// `SuiAddress.make("0x1")` validates the decoded representation and does
|
|
455
|
+
// not normalize it, so the abbreviated form threw inside the fake API and
|
|
456
|
+
// arrived as a `TransportError` from a member that never touched a network.
|
|
457
|
+
const address = await provide(Effect.flatMap(Escrow, (service) => service.feeCollector))
|
|
458
|
+
expect(String(address)).toBe(padded("1"))
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
describe("the Promise face: registering on a client", () => {
|
|
463
|
+
const apiKey = Redacted.make("test-key")
|
|
464
|
+
const options = { packageId: ESCROW_PACKAGE, url: "https://settlement.example", apiKey }
|
|
465
|
+
/** devnet has no built-in chain identifier, which is the whole point here. */
|
|
466
|
+
const LOCAL_CHAIN = "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"
|
|
467
|
+
|
|
468
|
+
/** The fake's own SDK client, which implements `$extend`. */
|
|
469
|
+
const fakeClientOf = (network: SuiClientTypes.Network, chainId: string) =>
|
|
470
|
+
Effect.runSync(
|
|
471
|
+
Effect.provide(
|
|
472
|
+
Effect.map(SuiCoreFake, (fake) => fake.client),
|
|
473
|
+
// The fake `SuiCore` alone, not `layerTest`: `layerTest` builds `Sui`,
|
|
474
|
+
// which reads the chain identifier, and the whole point here is a
|
|
475
|
+
// registration that builds synchronously.
|
|
476
|
+
SuiCoreFake.layer({ ...script, network, chainId }),
|
|
477
|
+
{ local: true }
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
test("a warm registration on a network with no built-in chain id needs one", () => {
|
|
482
|
+
// `warm` takes the chain identifier rather than asking the node for it, and
|
|
483
|
+
// devnet's is regenerated, so there is nothing to take. Without
|
|
484
|
+
// `options.chainId` this throws out of `register` — which is the failure a
|
|
485
|
+
// copier hits the first time they point the template at a local network.
|
|
486
|
+
const client = fakeClientOf("devnet", LOCAL_CHAIN)
|
|
487
|
+
expect(() => client.$extend(escrowRegistration(options))).toThrow(/chain id/)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
test("threading options.chainId makes the warm face work on devnet", async () => {
|
|
491
|
+
const client = fakeClientOf("devnet", LOCAL_CHAIN)
|
|
492
|
+
const extended = client.$extend(escrowRegistration({ ...options, chainId: LOCAL_CHAIN }))
|
|
493
|
+
// A synchronous member is the real value immediately: that is what `warm`
|
|
494
|
+
// buys, and reading it off a lazy registration would throw
|
|
495
|
+
// `ExtensionNotReady`.
|
|
496
|
+
expect(extended.escrow.packageId).toBe(ESCROW_PACKAGE)
|
|
497
|
+
const found = await extended.escrow.get(ESCROW_ID)
|
|
498
|
+
expect(found.content.amount).toBe("5")
|
|
499
|
+
await extended.escrow.$dispose()
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
test("two registrations naming one chain id both work off one client", async () => {
|
|
503
|
+
// The rule from `docs/extensions.md`: the base `Sui`, its transport and its
|
|
504
|
+
// sender-lock map are shared per client **per chain id**. Registering the
|
|
505
|
+
// escrow extension and the platform that composes it with the same id is
|
|
506
|
+
// what keeps two `Tx.run`s for one address serialized.
|
|
507
|
+
const client = fakeClientOf("devnet", LOCAL_CHAIN)
|
|
508
|
+
const extended = client
|
|
509
|
+
.$extend(escrowRegistration({ ...options, chainId: LOCAL_CHAIN }))
|
|
510
|
+
.$extend(platformRegistration({ ...options, chainId: LOCAL_CHAIN }))
|
|
511
|
+
expect(extended.escrow.packageId).toBe(ESCROW_PACKAGE)
|
|
512
|
+
expect(extended.platform.escrow.packageId).toBe(ESCROW_PACKAGE)
|
|
513
|
+
await extended.escrow.$dispose()
|
|
514
|
+
await extended.platform.$dispose()
|
|
515
|
+
})
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
describe("layerConfig validates through the typed deployment path", () => {
|
|
519
|
+
/**
|
|
520
|
+
* `Effect.withConfigProvider` does not exist in Effect v4 rc.112. A test
|
|
521
|
+
* provides the `ConfigProvider` the way it provides anything else — here with
|
|
522
|
+
* `ConfigProvider.layer`, which is `Effect.provideService(…,
|
|
523
|
+
* ConfigProvider.ConfigProvider, …)` as a layer.
|
|
524
|
+
*/
|
|
525
|
+
const withEnvironment = (values: Record<string, string>) =>
|
|
526
|
+
Effect.runPromiseExit(
|
|
527
|
+
Effect.provide(
|
|
528
|
+
Effect.map(Escrow, (service) => service.packageId),
|
|
529
|
+
Layer.mergeAll(
|
|
530
|
+
Escrow.layerConfig.pipe(
|
|
531
|
+
Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(values)))
|
|
532
|
+
),
|
|
533
|
+
Layer.empty
|
|
534
|
+
).pipe(Layer.provide(layerTest(script))),
|
|
535
|
+
{ local: true }
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
test("a well-formed package id builds the layer", async () => {
|
|
540
|
+
const exit = await withEnvironment({
|
|
541
|
+
ESCROW_PACKAGE_ID: padded("abc"),
|
|
542
|
+
ESCROW_URL: "https://settlement.example",
|
|
543
|
+
ESCROW_API_KEY: "secret"
|
|
544
|
+
})
|
|
545
|
+
expect(Exit.isSuccess(exit)).toBe(true)
|
|
546
|
+
if (Exit.isSuccess(exit)) expect(exit.value).toBe(padded("abc"))
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
test("a malformed package id is a ConfigError, not a Move abort three calls later", async () => {
|
|
550
|
+
const exit = await withEnvironment({
|
|
551
|
+
ESCROW_PACKAGE_ID: "not-an-object-id",
|
|
552
|
+
ESCROW_URL: "https://settlement.example",
|
|
553
|
+
ESCROW_API_KEY: "secret"
|
|
554
|
+
})
|
|
555
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
556
|
+
expect(String(exit)).toContain("PACKAGE_ID")
|
|
557
|
+
expect(String(exit)).toContain("32-byte Sui object id")
|
|
558
|
+
})
|
|
559
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": false,
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"rootDir": "src",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
// Inside the sui-effect repository the *build* resolves `sui-effect` to the
|
|
11
|
+
// library's emitted declarations rather than to its sources: a `.ts` file
|
|
12
|
+
// outside `rootDir` is a file tsc refuses to emit around, and a published
|
|
13
|
+
// package must not carry a path back into this repository either way.
|
|
14
|
+
// Delete this block when you copy the template out; `node_modules` then
|
|
15
|
+
// resolves it like any other dependency.
|
|
16
|
+
"paths": {
|
|
17
|
+
"sui-effect": ["../../dist/index.d.ts"],
|
|
18
|
+
"sui-effect/tx": ["../../dist/tx.d.ts"],
|
|
19
|
+
"sui-effect/journal": ["../../dist/journal.d.ts"],
|
|
20
|
+
"sui-effect/extension": ["../../dist/extension.d.ts"],
|
|
21
|
+
"sui-effect/script": ["../../dist/script.d.ts"],
|
|
22
|
+
"sui-effect/testing": ["../../dist/testing.d.ts"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"include": ["src/**/*.ts"]
|
|
26
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ESNext", "DOM"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"exactOptionalPropertyTypes": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"rewriteRelativeImportExtensions": true,
|
|
13
|
+
"noUnusedLocals": true,
|
|
14
|
+
"noUnusedParameters": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"types": ["bun"],
|
|
19
|
+
"baseUrl": ".",
|
|
20
|
+
// Inside the sui-effect repository the template resolves `sui-effect` to
|
|
21
|
+
// the library's own sources, so it needs no install of its own and there is
|
|
22
|
+
// exactly one copy of `effect` in play. Delete this block when you copy the
|
|
23
|
+
// template out, and add `sui-effect` to `dependencies` instead.
|
|
24
|
+
"paths": {
|
|
25
|
+
"sui-effect": ["../../src/index.ts"],
|
|
26
|
+
"sui-effect/tx": ["../../src/tx.ts"],
|
|
27
|
+
"sui-effect/journal": ["../../src/journal.ts"],
|
|
28
|
+
"sui-effect/extension": ["../../src/extension.ts"],
|
|
29
|
+
"sui-effect/script": ["../../src/script.ts"],
|
|
30
|
+
"sui-effect/testing": ["../../src/testing.ts"]
|
|
31
|
+
},
|
|
32
|
+
"plugins": [{ "name": "@effect/language-service" }]
|
|
33
|
+
},
|
|
34
|
+
"include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"]
|
|
35
|
+
}
|