@unconfirmed/sui-effect 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/AGENTS.md +209 -0
  2. package/LICENSE +21 -0
  3. package/LLMS.md +6707 -0
  4. package/README.md +300 -0
  5. package/dist/domain/bcs.d.ts +101 -0
  6. package/dist/domain/bcs.d.ts.map +1 -0
  7. package/dist/domain/bcs.js +183 -0
  8. package/dist/domain/bcs.js.map +1 -0
  9. package/dist/domain/errors.d.ts +595 -0
  10. package/dist/domain/errors.d.ts.map +1 -0
  11. package/dist/domain/errors.js +476 -0
  12. package/dist/domain/errors.js.map +1 -0
  13. package/dist/domain/executed.d.ts +325 -0
  14. package/dist/domain/executed.d.ts.map +1 -0
  15. package/dist/domain/executed.js +253 -0
  16. package/dist/domain/executed.js.map +1 -0
  17. package/dist/domain/journal-entry.d.ts +468 -0
  18. package/dist/domain/journal-entry.d.ts.map +1 -0
  19. package/dist/domain/journal-entry.js +63 -0
  20. package/dist/domain/journal-entry.js.map +1 -0
  21. package/dist/domain/schemas.d.ts +950 -0
  22. package/dist/domain/schemas.d.ts.map +1 -0
  23. package/dist/domain/schemas.js +571 -0
  24. package/dist/domain/schemas.js.map +1 -0
  25. package/dist/domain/sui-schema.d.ts +12 -0
  26. package/dist/domain/sui-schema.d.ts.map +1 -0
  27. package/dist/domain/sui-schema.js +12 -0
  28. package/dist/domain/sui-schema.js.map +1 -0
  29. package/dist/extension.d.ts +7 -0
  30. package/dist/extension.d.ts.map +1 -0
  31. package/dist/extension.js +7 -0
  32. package/dist/extension.js.map +1 -0
  33. package/dist/index.d.ts +31 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +31 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/internal.d.ts +18 -0
  38. package/dist/internal.d.ts.map +1 -0
  39. package/dist/internal.js +18 -0
  40. package/dist/internal.js.map +1 -0
  41. package/dist/journal.d.ts +51 -0
  42. package/dist/journal.d.ts.map +1 -0
  43. package/dist/journal.js +18 -0
  44. package/dist/journal.js.map +1 -0
  45. package/dist/script.d.ts +7 -0
  46. package/dist/script.d.ts.map +1 -0
  47. package/dist/script.js +7 -0
  48. package/dist/script.js.map +1 -0
  49. package/dist/services/Journal.d.ts +76 -0
  50. package/dist/services/Journal.d.ts.map +1 -0
  51. package/dist/services/Journal.js +70 -0
  52. package/dist/services/Journal.js.map +1 -0
  53. package/dist/services/JournalKeyValueStore.d.ts +73 -0
  54. package/dist/services/JournalKeyValueStore.d.ts.map +1 -0
  55. package/dist/services/JournalKeyValueStore.js +155 -0
  56. package/dist/services/JournalKeyValueStore.js.map +1 -0
  57. package/dist/services/Script.d.ts +210 -0
  58. package/dist/services/Script.d.ts.map +1 -0
  59. package/dist/services/Script.js +441 -0
  60. package/dist/services/Script.js.map +1 -0
  61. package/dist/services/Signer.d.ts +132 -0
  62. package/dist/services/Signer.d.ts.map +1 -0
  63. package/dist/services/Signer.js +158 -0
  64. package/dist/services/Signer.js.map +1 -0
  65. package/dist/services/SubmitConfig.d.ts +175 -0
  66. package/dist/services/SubmitConfig.d.ts.map +1 -0
  67. package/dist/services/SubmitConfig.js +52 -0
  68. package/dist/services/SubmitConfig.js.map +1 -0
  69. package/dist/services/Sui.d.ts +295 -0
  70. package/dist/services/Sui.d.ts.map +1 -0
  71. package/dist/services/Sui.js +414 -0
  72. package/dist/services/Sui.js.map +1 -0
  73. package/dist/services/SuiCore.d.ts +227 -0
  74. package/dist/services/SuiCore.d.ts.map +1 -0
  75. package/dist/services/SuiCore.js +400 -0
  76. package/dist/services/SuiCore.js.map +1 -0
  77. package/dist/services/SuiCoreFake.d.ts +278 -0
  78. package/dist/services/SuiCoreFake.d.ts.map +1 -0
  79. package/dist/services/SuiCoreFake.js +1003 -0
  80. package/dist/services/SuiCoreFake.js.map +1 -0
  81. package/dist/services/SuiExtension.d.ts +208 -0
  82. package/dist/services/SuiExtension.d.ts.map +1 -0
  83. package/dist/services/SuiExtension.js +355 -0
  84. package/dist/services/SuiExtension.js.map +1 -0
  85. package/dist/services/SuiGraphQL.d.ts +98 -0
  86. package/dist/services/SuiGraphQL.d.ts.map +1 -0
  87. package/dist/services/SuiGraphQL.js +118 -0
  88. package/dist/services/SuiGraphQL.js.map +1 -0
  89. package/dist/services/Tx.d.ts +687 -0
  90. package/dist/services/Tx.d.ts.map +1 -0
  91. package/dist/services/Tx.js +1224 -0
  92. package/dist/services/Tx.js.map +1 -0
  93. package/dist/testing.d.ts +88 -0
  94. package/dist/testing.d.ts.map +1 -0
  95. package/dist/testing.js +163 -0
  96. package/dist/testing.js.map +1 -0
  97. package/dist/tx.d.ts +24 -0
  98. package/dist/tx.d.ts.map +1 -0
  99. package/dist/tx.js +24 -0
  100. package/dist/tx.js.map +1 -0
  101. package/docs/extensions.md +1685 -0
  102. package/examples/extension-template/README.md +170 -0
  103. package/examples/extension-template/package.json +44 -0
  104. package/examples/extension-template/scripts/check-package.ts +147 -0
  105. package/examples/extension-template/src/Escrow.ts +445 -0
  106. package/examples/extension-template/src/Platform.ts +136 -0
  107. package/examples/extension-template/src/errors.ts +61 -0
  108. package/examples/extension-template/src/extension.ts +63 -0
  109. package/examples/extension-template/src/index.ts +34 -0
  110. package/examples/extension-template/src/schema.ts +158 -0
  111. package/examples/extension-template/src/upstream.ts +66 -0
  112. package/examples/extension-template/test/escrow.test.ts +559 -0
  113. package/examples/extension-template/tsconfig.build.json +26 -0
  114. package/examples/extension-template/tsconfig.json +35 -0
  115. 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 `@unconfirmed/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 "@unconfirmed/sui-effect"
26
+ import { KNOWN_CHAIN_IDS, ObjectId, SuiAddress, SuiSchema } from "@unconfirmed/sui-effect"
27
+ import { FakeOutcome, layerExtensionTest, layerTest, SuiCoreFake, SuiTest } from "@unconfirmed/sui-effect/testing"
28
+ import { Journal, Signer } from "@unconfirmed/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 `@unconfirmed/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
+ "@unconfirmed/sui-effect": ["../../dist/index.d.ts"],
18
+ "@unconfirmed/sui-effect/tx": ["../../dist/tx.d.ts"],
19
+ "@unconfirmed/sui-effect/journal": ["../../dist/journal.d.ts"],
20
+ "@unconfirmed/sui-effect/extension": ["../../dist/extension.d.ts"],
21
+ "@unconfirmed/sui-effect/script": ["../../dist/script.d.ts"],
22
+ "@unconfirmed/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 `@unconfirmed/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 `@unconfirmed/sui-effect` to `dependencies` instead.
24
+ "paths": {
25
+ "@unconfirmed/sui-effect": ["../../src/index.ts"],
26
+ "@unconfirmed/sui-effect/tx": ["../../src/tx.ts"],
27
+ "@unconfirmed/sui-effect/journal": ["../../src/journal.ts"],
28
+ "@unconfirmed/sui-effect/extension": ["../../src/extension.ts"],
29
+ "@unconfirmed/sui-effect/script": ["../../src/script.ts"],
30
+ "@unconfirmed/sui-effect/testing": ["../../src/testing.ts"]
31
+ },
32
+ "plugins": [{ "name": "@effect/language-service" }]
33
+ },
34
+ "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"]
35
+ }