@arkiv-network/sdk 0.7.0 → 0.8.0-dev.1

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 (210) hide show
  1. package/README.md +29 -16
  2. package/dist/attr/index.cjs +34 -0
  3. package/dist/attr/index.d.cts +2 -0
  4. package/dist/attr/index.d.ts +2 -0
  5. package/dist/attr/index.js +3 -0
  6. package/dist/attr-BDZ-APg5.js +809 -0
  7. package/dist/attr-BDZ-APg5.js.map +1 -0
  8. package/dist/attr-BvxHN99W.cjs +1029 -0
  9. package/dist/chains/index.cjs +2 -3
  10. package/dist/chains/index.d.cts +39 -77
  11. package/dist/chains/index.d.cts.map +1 -1
  12. package/dist/chains/index.d.ts +22 -60
  13. package/dist/chains/index.d.ts.map +1 -1
  14. package/dist/chains/index.js +2 -2
  15. package/dist/chains-BJrZs6sG.js +57 -0
  16. package/dist/chains-BJrZs6sG.js.map +1 -0
  17. package/dist/chains-BYRTTipa.cjs +73 -0
  18. package/dist/changeOwnership-B7bEgla4.d.cts +509 -0
  19. package/dist/changeOwnership-B7bEgla4.d.cts.map +1 -0
  20. package/dist/changeOwnership-DwgtdSFc.d.ts +509 -0
  21. package/dist/changeOwnership-DwgtdSFc.d.ts.map +1 -0
  22. package/dist/entity/index.cjs +19 -0
  23. package/dist/entity/index.d.cts +4 -0
  24. package/dist/entity/index.d.ts +4 -0
  25. package/dist/entity/index.js +6 -0
  26. package/dist/entity-B0t6sN7F.cjs +71 -0
  27. package/dist/entity-BIp_df9u.js +67 -0
  28. package/dist/entity-BIp_df9u.js.map +1 -0
  29. package/dist/entity-CFhbAv0R.js +70 -0
  30. package/dist/entity-CFhbAv0R.js.map +1 -0
  31. package/dist/entity-wfBnZd91.cjs +98 -0
  32. package/dist/errors-D-eJkGVc.cjs +56 -0
  33. package/dist/errors-DGB8AgT2.js +33 -0
  34. package/dist/errors-DGB8AgT2.js.map +1 -0
  35. package/dist/expiry-BUYIXTGb.cjs +135 -0
  36. package/dist/expiry-BxOlvVWC.d.ts +79 -0
  37. package/dist/expiry-BxOlvVWC.d.ts.map +1 -0
  38. package/dist/expiry-DGRzrSan.js +95 -0
  39. package/dist/expiry-DGRzrSan.js.map +1 -0
  40. package/dist/expiry-DRV29F1r.d.cts +79 -0
  41. package/dist/expiry-DRV29F1r.d.cts.map +1 -0
  42. package/dist/flags-0Vl5rrHq.d.cts +41 -0
  43. package/dist/flags-0Vl5rrHq.d.cts.map +1 -0
  44. package/dist/flags-Ct8k0gpB.cjs +43 -0
  45. package/dist/flags-ZtlvOIIk.js +33 -0
  46. package/dist/flags-ZtlvOIIk.js.map +1 -0
  47. package/dist/flags-wmcMoAnN.d.ts +41 -0
  48. package/dist/flags-wmcMoAnN.d.ts.map +1 -0
  49. package/dist/index-Cgw1u5aY.d.cts +278 -0
  50. package/dist/index-Cgw1u5aY.d.cts.map +1 -0
  51. package/dist/index-ClgIuxJ8.d.ts +159 -0
  52. package/dist/index-ClgIuxJ8.d.ts.map +1 -0
  53. package/dist/index-D0CmtiWT.d.cts +159 -0
  54. package/dist/index-D0CmtiWT.d.cts.map +1 -0
  55. package/dist/index-D8LZt-3J.d.cts +417 -0
  56. package/dist/index-D8LZt-3J.d.cts.map +1 -0
  57. package/dist/index-DVYFaXNV.d.ts +417 -0
  58. package/dist/index-DVYFaXNV.d.ts.map +1 -0
  59. package/dist/index-VCqgn29D.d.ts +278 -0
  60. package/dist/index-VCqgn29D.d.ts.map +1 -0
  61. package/dist/index.cjs +675 -357
  62. package/dist/index.d.cts +251 -203
  63. package/dist/index.d.cts.map +1 -1
  64. package/dist/index.d.ts +251 -203
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +626 -355
  67. package/dist/index.js.map +1 -1
  68. package/dist/payload-Bnw1hrvb.cjs +83 -0
  69. package/dist/payload-CIxSn_DW.js +67 -0
  70. package/dist/payload-CIxSn_DW.js.map +1 -0
  71. package/dist/payload-Dgx3PQeK.d.ts +94 -0
  72. package/dist/payload-Dgx3PQeK.d.ts.map +1 -0
  73. package/dist/payload-P0nWyjLg.d.cts +94 -0
  74. package/dist/payload-P0nWyjLg.d.cts.map +1 -0
  75. package/dist/query/index.cjs +24 -149
  76. package/dist/query/index.d.cts +5 -3
  77. package/dist/query/index.d.ts +5 -3
  78. package/dist/query/index.js +6 -137
  79. package/dist/queryBuilder-BXDvX1K_.cjs +1123 -0
  80. package/dist/queryBuilder-CbLL4pG5.js +963 -0
  81. package/dist/queryBuilder-CbLL4pG5.js.map +1 -0
  82. package/dist/selection-BvK6yX0g.d.cts +433 -0
  83. package/dist/selection-BvK6yX0g.d.cts.map +1 -0
  84. package/dist/selection-fpmI3ADQ.d.ts +433 -0
  85. package/dist/selection-fpmI3ADQ.d.ts.map +1 -0
  86. package/dist/types/index.cjs +4 -1
  87. package/dist/types/index.d.cts +7 -2
  88. package/dist/types/index.d.ts +7 -2
  89. package/dist/types/index.js +4 -1
  90. package/dist/utils/index.cjs +4 -17
  91. package/dist/utils/index.d.cts +3 -17
  92. package/dist/utils/index.d.ts +3 -17
  93. package/dist/utils/index.js +4 -18
  94. package/package.json +26 -5
  95. package/src/actions/public/getEntity.ts +27 -43
  96. package/src/actions/public/getEntityNonce.ts +30 -0
  97. package/src/actions/public/predictEntityKeys.test.ts +143 -0
  98. package/src/actions/public/predictEntityKeys.ts +138 -0
  99. package/src/actions/public/query.ts +33 -69
  100. package/src/actions/public/watchEntityEvents.test.ts +414 -0
  101. package/src/actions/public/watchEntityEvents.ts +225 -0
  102. package/src/actions/wallet/changeOwnership.ts +8 -8
  103. package/src/actions/wallet/createEntity.ts +104 -26
  104. package/src/actions/wallet/deleteEntity.ts +2 -3
  105. package/src/actions/wallet/extendEntity.ts +39 -16
  106. package/src/actions/wallet/mutateEntities.test.ts +76 -0
  107. package/src/actions/wallet/mutateEntities.ts +29 -74
  108. package/src/actions/wallet/patchEntity.ts +90 -0
  109. package/src/attr/attributes.test.ts +120 -0
  110. package/src/attr/attributes.ts +190 -0
  111. package/src/attr/codec.test.ts +162 -0
  112. package/src/attr/codec.ts +220 -0
  113. package/src/attr/decimal.ts +86 -0
  114. package/src/attr/errors.ts +145 -0
  115. package/src/attr/index.ts +50 -0
  116. package/src/attr/names.ts +106 -0
  117. package/src/attr/types.ts +150 -0
  118. package/src/attr/values.test.ts +271 -0
  119. package/src/attr/values.ts +502 -0
  120. package/src/chains/cheesecake.ts +22 -0
  121. package/src/chains/index.ts +1 -2
  122. package/src/clients/createPublicClient.test.ts +3 -5
  123. package/src/clients/createPublicClient.ts +5 -4
  124. package/src/clients/createWalletClient.test.ts +2 -2
  125. package/src/clients/createWalletClient.ts +13 -7
  126. package/src/clients/decorators/arkivPublic.ts +160 -139
  127. package/src/clients/decorators/arkivWallet.ts +91 -95
  128. package/src/consts.ts +2 -1
  129. package/src/entity/create.test.ts +184 -0
  130. package/src/entity/errors.ts +38 -0
  131. package/src/entity/events.ts +16 -0
  132. package/src/entity/expiry.test.ts +195 -0
  133. package/src/entity/expiry.ts +181 -0
  134. package/src/entity/flags.ts +65 -0
  135. package/src/entity/index.ts +14 -0
  136. package/src/entity/key.ts +68 -0
  137. package/src/entity/operations.ts +134 -0
  138. package/src/entity/params.ts +15 -0
  139. package/src/errors.ts +33 -28
  140. package/src/index.ts +6 -0
  141. package/src/query/engine.ts +59 -134
  142. package/src/query/errors.ts +151 -0
  143. package/src/query/expression.test.ts +286 -0
  144. package/src/query/expression.ts +493 -0
  145. package/src/query/index.ts +40 -4
  146. package/src/query/injection.test.ts +151 -0
  147. package/src/query/queryBuilder.test.ts +322 -637
  148. package/src/query/queryBuilder.ts +126 -359
  149. package/src/query/queryResult.ts +54 -51
  150. package/src/query/selection.test.ts +48 -98
  151. package/src/query/selection.ts +77 -65
  152. package/src/types/entity.test.ts +44 -155
  153. package/src/types/entity.ts +62 -44
  154. package/src/types/events.ts +64 -23
  155. package/src/types/index.ts +20 -16
  156. package/src/types/rpcSchema.ts +70 -33
  157. package/src/utils/arkivTransactions.test.ts +681 -38
  158. package/src/utils/arkivTransactions.ts +249 -104
  159. package/src/utils/chains.test.ts +21 -24
  160. package/src/utils/compression.ts +1 -1
  161. package/src/utils/entities.test.ts +62 -0
  162. package/src/utils/entities.ts +111 -29
  163. package/src/utils/expirationTime.ts +114 -10
  164. package/src/utils/logger.ts +2 -2
  165. package/src/utils/payload.ts +4 -2
  166. package/src/utils/revert.test.ts +99 -0
  167. package/src/utils/revert.ts +145 -0
  168. package/dist/chains-BifaY04V.cjs +0 -108
  169. package/dist/chains-CrtkVbBh.js +0 -86
  170. package/dist/chains-CrtkVbBh.js.map +0 -1
  171. package/dist/consts-AUoL-v42.cjs +0 -18
  172. package/dist/consts-BV4PR-de.js +0 -7
  173. package/dist/consts-BV4PR-de.js.map +0 -1
  174. package/dist/entity-B6g_EPm1.d.cts +0 -382
  175. package/dist/entity-B6g_EPm1.d.cts.map +0 -1
  176. package/dist/entity-qoLMAuuk.d.ts +0 -382
  177. package/dist/entity-qoLMAuuk.d.ts.map +0 -1
  178. package/dist/index-DdspxNAA.d.ts +0 -481
  179. package/dist/index-DdspxNAA.d.ts.map +0 -1
  180. package/dist/index-Ds9dwqb5.d.cts +0 -481
  181. package/dist/index-Ds9dwqb5.d.cts.map +0 -1
  182. package/dist/payload-B2kFJyah.d.ts +0 -6
  183. package/dist/payload-B2kFJyah.d.ts.map +0 -1
  184. package/dist/payload-CJN8fTvX.d.cts +0 -6
  185. package/dist/payload-CJN8fTvX.d.cts.map +0 -1
  186. package/dist/payload-Claf2jUO.js +0 -13
  187. package/dist/payload-Claf2jUO.js.map +0 -1
  188. package/dist/payload-DpA5PA-w.cjs +0 -23
  189. package/dist/query/index.js.map +0 -1
  190. package/dist/queryBuilder-D2uwP9jI.js +0 -704
  191. package/dist/queryBuilder-D2uwP9jI.js.map +0 -1
  192. package/dist/queryBuilder-UW2MCaX4.cjs +0 -792
  193. package/dist/utils/index.d.cts.map +0 -1
  194. package/dist/utils/index.d.ts.map +0 -1
  195. package/dist/utils/index.js.map +0 -1
  196. package/src/actions/public/buildQuery.ts +0 -10
  197. package/src/actions/public/subscribeEntityEvents.ts +0 -119
  198. package/src/actions/wallet/updateEntity.ts +0 -60
  199. package/src/chains/braga.ts +0 -26
  200. package/src/chains/kaolin.ts +0 -29
  201. package/src/query/engine.test.ts +0 -526
  202. package/src/query/predicate.test.ts +0 -461
  203. package/src/query/predicate.ts +0 -144
  204. package/src/query/queryResult.test.ts +0 -270
  205. package/src/query/selectQueryBuilder.test.ts +0 -210
  206. package/src/types/attributes.ts +0 -16
  207. package/src/utils/validation.test.ts +0 -60
  208. package/src/utils/validation.ts +0 -32
  209. /package/dist/{types-H-TSmRpm.js → types-Bx1EKe0v.js} +0 -0
  210. /package/dist/{types-o3zWarRp.cjs → types-CKPddlfS.cjs} +0 -0
@@ -1,62 +1,705 @@
1
- import { describe, expect, it } from "bun:test"
2
- import { InvalidAttributeError, InvalidExpirationError } from "../errors"
3
- import { opsToTxData } from "./arkivTransactions"
1
+ import { describe, expect, it, vi } from "bun:test"
2
+ import {
3
+ type Abi,
4
+ BaseError,
5
+ ContractFunctionRevertedError,
6
+ decodeAbiParameters,
7
+ encodeAbiParameters,
8
+ encodeEventTopics,
9
+ getAbiItem,
10
+ type Hex,
11
+ parseAbiParameters,
12
+ toHex,
13
+ } from "viem"
14
+ import {
15
+ type AttributeInputs,
16
+ ConflictingMutationError,
17
+ dec,
18
+ InvalidAttributeNameError,
19
+ InvalidValueError,
20
+ i32,
21
+ MissingValueError,
22
+ u256,
23
+ } from "../attr"
24
+ import type { ArkivClient } from "../clients/baseClient"
25
+ import { ARKIV_ADDRESS } from "../consts"
26
+ import { InvalidExpiryError } from "../entity"
27
+ import { ENTITY_EVENTS_ABI, type EntityEventName } from "../entity/events"
28
+ import { MAX_EXPIRES_AT, OperationType } from "../entity/params"
29
+ import { EmptyPatchError, EntityMutationError } from "../errors"
30
+ import { sendArkivTransaction } from "./arkivTransactions"
31
+ import { ExpirationTime } from "./expirationTime"
4
32
 
5
- const validCreate = {
33
+ const ENTITY_KEY = `0x${"ab".repeat(32)}` as const
34
+ const OWNER = "0x1111111111111111111111111111111111111111" as const
35
+ const CURRENT_BLOCK = 100n
36
+ const OWNER_NONCE = 7n
37
+
38
+ const CREATE_PARAMS = parseAbiParameters(
39
+ "(uint128 salt, uint64 expiresAt, uint64 minLifetime, uint8 creationFlags, (bytes32 name, uint8 typeId, bytes value)[] attributes)",
40
+ )
41
+
42
+ const EXTEND_PARAMS = parseAbiParameters(
43
+ "(bytes32 entityKey, uint64 expiresAt, uint64 minLifetime)",
44
+ )
45
+
46
+ const PATCH_PARAMS = parseAbiParameters(
47
+ "(bytes32 entityKey, (bytes32 name, uint8 typeId, bytes value)[] mutations)",
48
+ )
49
+
50
+ const mintedKey = (index: number) =>
51
+ `0x${(index + 1).toString(16).padStart(2, "0").repeat(32)}` as Hex
52
+
53
+ /** What the engine stores: `max(expiresAt, currentBlock + minLifetime)`, resolved at execution. */
54
+ const engineExpiry = (expiresAt: bigint, minLifetime: bigint) =>
55
+ expiresAt > CURRENT_BLOCK + minLifetime ? expiresAt : CURRENT_BLOCK + minLifetime
56
+
57
+ /** One receipt log, built from the same ABI the SDK reads it back with. */
58
+ function entityLog(eventName: EntityEventName, args: Record<string, unknown>) {
59
+ const item = getAbiItem({ abi: ENTITY_EVENTS_ABI as Abi, name: eventName })
60
+ if (item === undefined || item.type !== "event") throw new Error(`no event ${eventName}`)
61
+ const unindexed = item.inputs.filter((input) => !("indexed" in input && input.indexed))
62
+ return {
63
+ address: ARKIV_ADDRESS,
64
+ // biome-ignore lint/suspicious/noExplicitAny: the arg shape varies per event by design.
65
+ topics: encodeEventTopics({ abi: ENTITY_EVENTS_ABI, eventName, args: args as any }),
66
+ data:
67
+ unindexed.length === 0
68
+ ? "0x"
69
+ : encodeAbiParameters(
70
+ unindexed,
71
+ unindexed.map((input) => args[input.name ?? ""]),
72
+ ),
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The events the engine would emit for what was actually put on the wire: one `EntityCreated` per
78
+ * create and one `ExpiryExtended` per extension, each carrying the expiry the engine resolved
79
+ * rather than the one the SDK asked for.
80
+ */
81
+ function emittedLogs(writeContract: ReturnType<typeof vi.fn>) {
82
+ const logs: ReturnType<typeof entityLog>[] = []
83
+ let createIndex = 0
84
+ for (const op of sentOperations(writeContract)) {
85
+ if (op.operation === OperationType.Create) {
86
+ const create = decodeAbiParameters(CREATE_PARAMS, op.operationData)[0]
87
+ logs.push(
88
+ entityLog("EntityCreated", {
89
+ entityKey: mintedKey(createIndex++),
90
+ owner: OWNER,
91
+ expiresAt: engineExpiry(create.expiresAt, create.minLifetime),
92
+ creationFlags: create.creationFlags,
93
+ }),
94
+ )
95
+ } else if (op.operation === OperationType.ExtendExpiry) {
96
+ const extend = decodeAbiParameters(EXTEND_PARAMS, op.operationData)[0]
97
+ logs.push(
98
+ entityLog("ExpiryExtended", {
99
+ entityKey: extend.entityKey,
100
+ owner: OWNER,
101
+ expiresAt: engineExpiry(extend.expiresAt, extend.minLifetime),
102
+ }),
103
+ )
104
+ }
105
+ }
106
+ return logs
107
+ }
108
+
109
+ function makeClient() {
110
+ const writeContract = vi.fn().mockResolvedValue("0xdeadbeef")
111
+ const waitForTransactionReceipt = vi.fn(async () => ({
112
+ status: "success",
113
+ transactionHash: "0xdeadbeef",
114
+ logs: emittedLogs(writeContract),
115
+ }))
116
+ const readContract = vi.fn().mockResolvedValue(OWNER_NONCE)
117
+ return {
118
+ client: {
119
+ account: { address: OWNER },
120
+ chain: { id: 1 },
121
+ getBlockNumber: vi.fn().mockResolvedValue(CURRENT_BLOCK),
122
+ readContract,
123
+ writeContract,
124
+ waitForTransactionReceipt,
125
+ } as unknown as ArkivClient,
126
+ writeContract,
127
+ waitForTransactionReceipt,
128
+ readContract,
129
+ }
130
+ }
131
+
132
+ /** The operations a call put on the wire. */
133
+ function sentOperations(writeContract: ReturnType<typeof vi.fn>) {
134
+ return writeContract.mock.calls[0][0].args[0] as {
135
+ operation: number
136
+ operationData: `0x${string}`
137
+ }[]
138
+ }
139
+
140
+ /** The decoded Create struct of the first operation. */
141
+ function sentCreate(writeContract: ReturnType<typeof vi.fn>) {
142
+ const [op] = sentOperations(writeContract)
143
+ expect(op.operation).toBe(OperationType.Create)
144
+ return decodeAbiParameters(CREATE_PARAMS, op.operationData)[0]
145
+ }
146
+
147
+ /** The decoded ExtendExpiry struct of the batch's only extension. */
148
+ function sentExtension(writeContract: ReturnType<typeof vi.fn>) {
149
+ const op = sentOperations(writeContract).find((o) => o.operation === OperationType.ExtendExpiry)
150
+ if (!op) throw new Error("no ExtendExpiry operation was sent")
151
+ return decodeAbiParameters(EXTEND_PARAMS, op.operationData)[0]
152
+ }
153
+
154
+ const CONTENTS = {
6
155
  payload: new Uint8Array([1, 2, 3]),
7
- contentType: "text/plain",
8
- attributes: [{ key: "k", value: "v" as string | number }],
9
- expiresIn: 1000,
156
+ contentType: "application/octet-stream",
10
157
  }
11
158
 
12
- describe("opsToTxData expiration validation", () => {
13
- it("builds tx data for a valid create", () => {
14
- expect(typeof opsToTxData({ creates: [validCreate] })).toBe("string")
159
+ const DAY = ExpirationTime.fromDays(1) // 86400s -> 43200 blocks at 2s
160
+ const validCreate = { ...CONTENTS, expires: DAY }
161
+
162
+ describe("create expiry", () => {
163
+ it("sends a create for a valid expiry", async () => {
164
+ const { client, writeContract } = makeClient()
165
+ const result = await sendArkivTransaction(client, { creates: [validCreate] })
166
+ expect(writeContract).toHaveBeenCalledTimes(1)
167
+ expect(result.receipt.status).toBe("success")
15
168
  })
16
169
 
17
- it("throws when a create expiresIn is not a multiple of the block time", () => {
18
- expect(() => opsToTxData({ creates: [{ ...validCreate, expiresIn: 51 }] })).toThrow(
19
- InvalidExpirationError,
20
- )
170
+ it("puts the expiry pair on the wire untouched, for the engine to resolve", async () => {
171
+ const { client, writeContract } = makeClient()
172
+ await sendArkivTransaction(client, {
173
+ creates: [
174
+ {
175
+ ...CONTENTS,
176
+ expires: ExpirationTime.atBlock(5_000n, { atLeast: ExpirationTime.fromSeconds(200) }),
177
+ },
178
+ ],
179
+ })
180
+ const create = sentCreate(writeContract)
181
+ expect(create.expiresAt).toBe(5_000n)
182
+ expect(create.minLifetime).toBe(100n) // 200s at 2s/block
21
183
  })
22
184
 
23
- it("throws when an update expiresIn is not an integer", () => {
24
- expect(() =>
25
- opsToTxData({
26
- updates: [{ ...validCreate, entityKey: "0x123", expiresIn: 51.5 }],
185
+ it("reports the block the entity will actually expire at", async () => {
186
+ const { client } = makeClient()
187
+ const relative = await sendArkivTransaction(client, {
188
+ creates: [{ ...CONTENTS, expires: DAY }],
189
+ })
190
+ expect(relative.createdExpiries[0]).toBe(CURRENT_BLOCK + 43_200n)
191
+
192
+ const { client: client2 } = makeClient()
193
+ const absolute = await sendArkivTransaction(client2, {
194
+ creates: [{ ...CONTENTS, expires: ExpirationTime.atBlock(5_000n) }],
195
+ })
196
+ expect(absolute.createdExpiries[0]).toBe(5_000n)
197
+
198
+ const { client: client3 } = makeClient()
199
+ const dated = await sendArkivTransaction(client3, {
200
+ creates: [{ ...CONTENTS, expires: ExpirationTime.atDate(new Date(Date.now() + 3600_000)) }],
201
+ })
202
+ // An hour at 2s/block, counted from the block the transaction was built on.
203
+ expect(dated.createdExpiries[0]).toBe(CURRENT_BLOCK + 1800n)
204
+ })
205
+
206
+ it("refuses a create with no expiry at all", async () => {
207
+ const { client } = makeClient()
208
+ await expect(
209
+ sendArkivTransaction(client, { creates: [{ ...CONTENTS } as never] }),
210
+ ).rejects.toThrow(InvalidExpiryError)
211
+ })
212
+
213
+ it("catches a dead-on-arrival expiry before it costs gas", async () => {
214
+ const { client, writeContract } = makeClient()
215
+ await expect(
216
+ sendArkivTransaction(client, {
217
+ creates: [{ ...CONTENTS, expires: ExpirationTime.atBlock(CURRENT_BLOCK - 1n) }],
27
218
  }),
28
- ).toThrow(InvalidExpirationError)
219
+ ).rejects.toThrow(/dead on arrival/)
220
+ expect(writeContract).not.toHaveBeenCalled()
221
+ })
222
+
223
+ it("rejects a malformed duration at the call site, long before the transaction", () => {
224
+ // 999 seconds is 499.5 blocks, which is not a lifetime the chain can store. Building the value
225
+ // throws, so nothing malformed ever reaches sendArkivTransaction.
226
+ expect(() => ExpirationTime.fromSeconds(999)).toThrow(/multiple of the 2s block time/)
227
+ expect(() => ExpirationTime.fromSeconds(0)).toThrow(InvalidExpiryError)
228
+ expect(() => ExpirationTime.fromSeconds(1.5)).toThrow(/positive whole number of seconds/)
229
+ })
230
+ })
231
+
232
+ describe("extend expiry", () => {
233
+ it("resolves an extension exactly like a create, both forms", async () => {
234
+ const { client, writeContract } = makeClient()
235
+ const relative = await sendArkivTransaction(client, {
236
+ extensions: [{ entityKey: ENTITY_KEY, expires: DAY }],
237
+ })
238
+ const extension = sentExtension(writeContract)
239
+ expect(extension.entityKey).toBe(ENTITY_KEY)
240
+ // A duration is a floor measured from now, not an increment on the entity's current expiry.
241
+ expect(extension.minLifetime).toBe(43_200n)
242
+ expect(extension.expiresAt).toBe(0n)
243
+ expect(relative.extendedExpiries[0]).toBe(CURRENT_BLOCK + 43_200n)
244
+
245
+ const { client: client2, writeContract: writeContract2 } = makeClient()
246
+ const absolute = await sendArkivTransaction(client2, {
247
+ extensions: [{ entityKey: ENTITY_KEY, expires: ExpirationTime.atBlock(5_000n) }],
248
+ })
249
+ expect(sentExtension(writeContract2).expiresAt).toBe(5_000n)
250
+ expect(absolute.extendedExpiries[0]).toBe(5_000n)
251
+ })
252
+
253
+ it("checks the wire bounds before spending gas, as a create does", async () => {
254
+ const { client, writeContract } = makeClient()
255
+ const tooFar = ExpirationTime.atBlock(MAX_EXPIRES_AT + 1n)
256
+ await expect(
257
+ sendArkivTransaction(client, { extensions: [{ entityKey: ENTITY_KEY, expires: tooFar }] }),
258
+ ).rejects.toThrow(/does not fit the uint64/)
259
+ expect(writeContract).not.toHaveBeenCalled()
260
+ })
261
+
262
+ it("refuses an extension that says nothing", async () => {
263
+ const { client } = makeClient()
264
+ await expect(
265
+ sendArkivTransaction(client, { extensions: [{ entityKey: ENTITY_KEY } as never] }),
266
+ ).rejects.toThrow(/must be built with ExpirationTime/)
267
+ })
268
+
269
+ it("reads the block height for an extension-only batch", async () => {
270
+ const { client, writeContract } = makeClient()
271
+ await sendArkivTransaction(client, {
272
+ extensions: [
273
+ { entityKey: ENTITY_KEY, expires: ExpirationTime.atDate(new Date(Date.now() + 3600_000)) },
274
+ ],
275
+ })
276
+ // A Date can only be placed once the current block is known, so it must have been fetched even
277
+ // though the batch creates nothing.
278
+ expect(sentExtension(writeContract).expiresAt).toBe(CURRENT_BLOCK + 1800n)
279
+ })
280
+ })
281
+
282
+ describe("creation flags and salt", () => {
283
+ it("packs flags into the creationFlags byte", async () => {
284
+ const { client, writeContract } = makeClient()
285
+ await sendArkivTransaction(client, {
286
+ creates: [{ ...validCreate, flags: { readonly: true, permissionlessExtension: true } }],
287
+ })
288
+ expect(sentCreate(writeContract).creationFlags).toBe(0b11)
289
+ })
290
+
291
+ it("defaults flags to none", async () => {
292
+ const { client, writeContract } = makeClient()
293
+ await sendArkivTransaction(client, { creates: [validCreate] })
294
+ expect(sentCreate(writeContract).creationFlags).toBe(0)
295
+ })
296
+
297
+ it("defaults the salt to random bits, and honours an explicit one", async () => {
298
+ const { client, writeContract } = makeClient()
299
+ await sendArkivTransaction(client, { creates: [validCreate] })
300
+ expect(sentCreate(writeContract).salt).toBeGreaterThan(0n)
301
+
302
+ const { client: c2, writeContract: w2 } = makeClient()
303
+ await sendArkivTransaction(c2, { creates: [{ ...validCreate, salt: 0n }] })
304
+ expect(sentCreate(w2).salt).toBe(0n)
305
+ })
306
+
307
+ it("rejects a salt wider than the uint128 field", async () => {
308
+ const { client } = makeClient()
309
+ await expect(
310
+ sendArkivTransaction(client, { creates: [{ ...validCreate, salt: 2n ** 128n }] }),
311
+ ).rejects.toThrow(/uint128/)
312
+ })
313
+ })
314
+
315
+ describe("entity keys", () => {
316
+ it("reports the key the engine minted, in batch order", async () => {
317
+ const { client } = makeClient()
318
+ const result = await sendArkivTransaction(client, {
319
+ creates: [
320
+ { ...validCreate, salt: 1n },
321
+ { ...validCreate, salt: 1n },
322
+ { ...validCreate, salt: 1n },
323
+ ],
324
+ })
325
+ // These come from the EntityCreated logs, so they are what the engine recorded rather than
326
+ // what the SDK guessed — and the same salt three times cannot make them collide.
327
+ expect(result.createdEntityKeys).toEqual([mintedKey(0), mintedKey(1), mintedKey(2)])
328
+ })
329
+
330
+ it("never reads the entity nonce, so two creates in flight cannot collide", async () => {
331
+ // The nonce read was the race: two creates issued before either was mined both read the same
332
+ // value, and one of them then returned a key the engine never minted. Reading the key back
333
+ // from the receipt removes the window rather than narrowing it — and saves a round trip.
334
+ const { client, readContract } = makeClient()
335
+ await sendArkivTransaction(client, { creates: [validCreate] })
336
+ expect(readContract).not.toHaveBeenCalled()
29
337
  })
30
338
 
31
- it("throws when an extension expiresIn is not a multiple of the block time", () => {
32
- expect(() => opsToTxData({ extensions: [{ entityKey: "0x123", expiresIn: 999 }] })).toThrow(
33
- InvalidExpirationError,
339
+ it("reports the expiry the engine resolved, not the one the SDK asked for", async () => {
340
+ // The engine applies max(expiresAt, currentBlock + minLifetime) against the block the batch
341
+ // actually lands on. Computing it here is a guess about which block that is.
342
+ const { client, waitForTransactionReceipt } = makeClient()
343
+ waitForTransactionReceipt.mockResolvedValue({
344
+ status: "success",
345
+ transactionHash: "0xdeadbeef",
346
+ logs: [
347
+ entityLog("EntityCreated", {
348
+ entityKey: mintedKey(0),
349
+ owner: OWNER,
350
+ expiresAt: 999_999n,
351
+ creationFlags: 0,
352
+ }),
353
+ ],
354
+ })
355
+
356
+ const result = await sendArkivTransaction(client, { creates: [validCreate] })
357
+ expect(result.createdExpiries[0]).toBe(999_999n)
358
+ })
359
+
360
+ it("ignores logs from another contract in the same transaction", async () => {
361
+ const { client, waitForTransactionReceipt } = makeClient()
362
+ const created = entityLog("EntityCreated", {
363
+ entityKey: mintedKey(0),
364
+ owner: OWNER,
365
+ expiresAt: 500n,
366
+ creationFlags: 0,
367
+ })
368
+ waitForTransactionReceipt.mockResolvedValue({
369
+ status: "success",
370
+ transactionHash: "0xdeadbeef",
371
+ logs: [{ ...created, address: "0x9999999999999999999999999999999999999999" }, created],
372
+ })
373
+
374
+ const result = await sendArkivTransaction(client, { creates: [validCreate] })
375
+ expect(result.createdEntityKeys).toEqual([mintedKey(0)])
376
+ })
377
+
378
+ it("refuses to guess when the events do not account for every create", async () => {
379
+ const { client, waitForTransactionReceipt } = makeClient()
380
+ waitForTransactionReceipt.mockResolvedValue({
381
+ status: "success",
382
+ transactionHash: "0xdeadbeef",
383
+ logs: [],
384
+ })
385
+
386
+ const failure = await sendArkivTransaction(client, {
387
+ creates: [validCreate, validCreate],
388
+ }).catch((error: Error) => error)
389
+
390
+ expect(failure).toBeInstanceOf(EntityMutationError)
391
+ // This throws *after* a successful write, so the message has to stop a caller reading it as a
392
+ // failure and retrying — which would create the entities a second time.
393
+ expect(failure.message).toContain("do not retry")
394
+ expect(failure.message).toContain("0xdeadbeef")
395
+ })
396
+ })
397
+
398
+ describe("attribute typing through the write path", () => {
399
+ it("rejects a value that does not fit the type it defaults to", async () => {
400
+ const { client } = makeClient()
401
+ await expect(
402
+ sendArkivTransaction(client, { creates: [{ ...validCreate, attributes: { n: 1.5 } }] }),
403
+ ).rejects.toThrow(InvalidValueError)
404
+ })
405
+
406
+ it("rejects an attribute name outside the grammar", async () => {
407
+ const { client } = makeClient()
408
+ await expect(
409
+ sendArkivTransaction(client, { creates: [{ ...validCreate, attributes: { "1bad": 1 } }] }),
410
+ ).rejects.toThrow(InvalidAttributeNameError)
411
+ })
412
+
413
+ it("rejects an attribute whose value is absent, rather than writing a typeless one", async () => {
414
+ const { client } = makeClient()
415
+ await expect(
416
+ sendArkivTransaction(client, {
417
+ creates: [
418
+ {
419
+ ...validCreate,
420
+ // What a spread of an optional-shaped object produces at runtime.
421
+ attributes: { category: "docs", score: undefined } as unknown as AttributeInputs,
422
+ },
423
+ ],
424
+ }),
425
+ ).rejects.toThrow(MissingValueError)
426
+ })
427
+
428
+ it("carries only the system cells when there are no user attributes", async () => {
429
+ const { client, writeContract } = makeClient()
430
+ await sendArkivTransaction(client, { creates: [validCreate] })
431
+ expect(sentCreate(writeContract).attributes.map((a) => a.name)).toEqual([
432
+ toHex("$contentType", { size: 32 }),
433
+ toHex("$payload", { size: 32 }),
434
+ ])
435
+ })
436
+
437
+ it("writes an empty payload rather than dropping it", async () => {
438
+ const { client, writeContract } = makeClient()
439
+ await sendArkivTransaction(client, {
440
+ creates: [{ ...validCreate, payload: new Uint8Array() }],
441
+ })
442
+ const byName = Object.fromEntries(sentCreate(writeContract).attributes.map((a) => [a.name, a]))
443
+ expect(byName[toHex("$payload", { size: 32 })]).toMatchObject({ typeId: 7, value: "0x" })
444
+ })
445
+
446
+ it("carries the protocol typeId of whichever type the value names", async () => {
447
+ const { client, writeContract } = makeClient()
448
+ await sendArkivTransaction(client, {
449
+ creates: [
450
+ { ...validCreate, attributes: { count: 42, balance: u256(1n), score: dec("3.5") } },
451
+ ],
452
+ })
453
+ const byName = Object.fromEntries(
454
+ sentCreate(writeContract).attributes.map((a) => [a.name, a.typeId]),
34
455
  )
456
+ expect(byName[toHex("count", { size: 32 })]).toBe(2)
457
+ expect(byName[toHex("balance", { size: 32 })]).toBe(4)
458
+ expect(byName[toHex("score", { size: 32 })]).toBe(5)
459
+ })
460
+
461
+ it("sends the payload and content type as system cells", async () => {
462
+ const { client, writeContract } = makeClient()
463
+ await sendArkivTransaction(client, {
464
+ creates: [
465
+ {
466
+ ...validCreate,
467
+ payload: new Uint8Array([1, 2, 3]),
468
+ contentType: "application/json",
469
+ attributes: { level: i32(1) },
470
+ },
471
+ ],
472
+ })
473
+ const byName = Object.fromEntries(sentCreate(writeContract).attributes.map((a) => [a.name, a]))
474
+ expect(byName[toHex("$payload", { size: 32 })]).toMatchObject({ typeId: 7, value: "0x010203" })
475
+ expect(byName[toHex("$contentType", { size: 32 })].typeId).toBe(8)
476
+ })
477
+
478
+ it("rejects a content type that is not a MIME token", async () => {
479
+ const { client } = makeClient()
480
+ await expect(
481
+ sendArkivTransaction(client, { creates: [{ ...validCreate, contentType: "Not A Mime" }] }),
482
+ ).rejects.toThrow(/Invalid content type/)
483
+ })
484
+
485
+ it("sorts attributes ascending by their bytes32 name", async () => {
486
+ const { client, writeContract } = makeClient()
487
+ await sendArkivTransaction(client, {
488
+ creates: [{ ...validCreate, attributes: { tag: "zz", status: "active", score: 30 } }],
489
+ })
490
+ const names = sentCreate(writeContract).attributes.map((a) => a.name)
491
+ expect(names).toEqual([...names].sort())
492
+ // The "$" system cells lead; the user attributes follow in name order.
493
+ expect(names.slice(2)).toEqual([
494
+ toHex("score", { size: 32 }),
495
+ toHex("status", { size: 32 }),
496
+ toHex("tag", { size: 32 }),
497
+ ])
35
498
  })
36
499
  })
37
500
 
38
- describe("opsToTxData attribute validation", () => {
39
- it("throws when a numeric attribute value is not an integer", () => {
40
- expect(() =>
41
- opsToTxData({
42
- creates: [{ ...validCreate, attributes: [{ key: "n", value: 1.5 }] }],
501
+ describe("patch", () => {
502
+ /** The decoded Patch struct of the batch's only patch. */
503
+ function sentPatch(writeContract: ReturnType<typeof vi.fn>) {
504
+ const op = sentOperations(writeContract).find((o) => o.operation === OperationType.Patch)
505
+ if (!op) throw new Error("no Patch operation was sent")
506
+ return decodeAbiParameters(PATCH_PARAMS, op.operationData)[0]
507
+ }
508
+
509
+ it("carries only what the patch named", async () => {
510
+ const { client, writeContract } = makeClient()
511
+ await sendArkivTransaction(client, {
512
+ patches: [{ entityKey: ENTITY_KEY, set: { level: i32(11) } }],
513
+ })
514
+ const patch = sentPatch(writeContract)
515
+ expect(patch.entityKey).toBe(ENTITY_KEY)
516
+ // No $payload, no $contentType, no tombstones: everything unnamed is left alone.
517
+ expect(patch.mutations).toEqual([
518
+ { name: toHex("level", { size: 32 }), typeId: 2, value: expect.any(String) },
519
+ ])
520
+ })
521
+
522
+ it("writes the system cells only when the patch gives them", async () => {
523
+ const { client, writeContract } = makeClient()
524
+ await sendArkivTransaction(client, {
525
+ patches: [{ entityKey: ENTITY_KEY, payload: new Uint8Array([9]) }],
526
+ })
527
+ const names = sentPatch(writeContract).mutations.map((m) => m.name)
528
+ expect(names).toEqual([toHex("$payload", { size: 32 })])
529
+ })
530
+
531
+ it("tombstones an unset attribute with typeId 0 and an empty value", async () => {
532
+ const { client, writeContract } = makeClient()
533
+ await sendArkivTransaction(client, {
534
+ patches: [{ entityKey: ENTITY_KEY, unset: ["draft"] }],
535
+ })
536
+ expect(sentPatch(writeContract).mutations).toEqual([
537
+ { name: toHex("draft", { size: 32 }), typeId: 0, value: "0x" },
538
+ ])
539
+ })
540
+
541
+ it("sorts writes and tombstones together, not each half on its own", async () => {
542
+ const { client, writeContract } = makeClient()
543
+ await sendArkivTransaction(client, {
544
+ patches: [
545
+ {
546
+ entityKey: ENTITY_KEY,
547
+ set: { zeta: "z", beta: "b" },
548
+ unset: ["alpha", "gamma"],
549
+ payload: new Uint8Array([1]),
550
+ },
551
+ ],
552
+ })
553
+ const names = sentPatch(writeContract).mutations.map((m) => m.name)
554
+ expect(names).toEqual([
555
+ toHex("$payload", { size: 32 }),
556
+ toHex("alpha", { size: 32 }),
557
+ toHex("beta", { size: 32 }),
558
+ toHex("gamma", { size: 32 }),
559
+ toHex("zeta", { size: 32 }),
560
+ ])
561
+ })
562
+
563
+ it("collapses a name repeated in unset, since it is the same intent twice", async () => {
564
+ const { client, writeContract } = makeClient()
565
+ await sendArkivTransaction(client, {
566
+ patches: [{ entityKey: ENTITY_KEY, unset: ["draft", "draft"] }],
567
+ })
568
+ // Two identical tombstones would break the engine's strict-ascending uniqueness rule.
569
+ expect(sentPatch(writeContract).mutations).toHaveLength(1)
570
+ })
571
+
572
+ it("rejects a name that is both set and unset", async () => {
573
+ const { client, writeContract } = makeClient()
574
+ await expect(
575
+ sendArkivTransaction(client, {
576
+ patches: [{ entityKey: ENTITY_KEY, set: { level: i32(1) }, unset: ["level"] }],
43
577
  }),
44
- ).toThrow(InvalidAttributeError)
578
+ ).rejects.toThrow(ConflictingMutationError)
579
+ expect(writeContract).not.toHaveBeenCalled()
580
+ })
581
+
582
+ it("rejects a patch with nothing to apply", async () => {
583
+ const { client, writeContract } = makeClient()
584
+ await expect(
585
+ sendArkivTransaction(client, { patches: [{ entityKey: ENTITY_KEY }] }),
586
+ ).rejects.toThrow(EmptyPatchError)
587
+ // Present but empty is just as empty.
588
+ await expect(
589
+ sendArkivTransaction(client, { patches: [{ entityKey: ENTITY_KEY, set: {}, unset: [] }] }),
590
+ ).rejects.toThrow(EmptyPatchError)
591
+ expect(writeContract).not.toHaveBeenCalled()
45
592
  })
46
593
 
47
- it("accepts integer numeric attribute values", () => {
48
- expect(
49
- typeof opsToTxData({
50
- creates: [{ ...validCreate, attributes: [{ key: "n", value: 123 }] }],
594
+ it("still validates a content type when one is given", async () => {
595
+ const { client } = makeClient()
596
+ await expect(
597
+ sendArkivTransaction(client, {
598
+ patches: [{ entityKey: ENTITY_KEY, payload: new Uint8Array(), contentType: "Not A Mime" }],
51
599
  }),
52
- ).toBe("string")
600
+ ).rejects.toThrow(/Invalid content type/)
53
601
  })
54
602
 
55
- it("accepts string attribute values that look like non-integers", () => {
56
- expect(
57
- typeof opsToTxData({
58
- creates: [{ ...validCreate, attributes: [{ key: "n", value: "1.5" }] }],
603
+ it("rejects a bare string as unset, rather than tombstoning its letters", async () => {
604
+ const { client, writeContract } = makeClient()
605
+ // A Set is built from any iterable, so "draft" would become tombstones for d, r, a, f and t —
606
+ // five valid attribute names, none of them the one meant, and the real "draft" left untouched.
607
+ await expect(
608
+ sendArkivTransaction(client, {
609
+ patches: [{ entityKey: ENTITY_KEY, unset: "draft" as unknown as string[] }],
59
610
  }),
60
- ).toBe("string")
611
+ ).rejects.toThrow(/must be an array of attribute names/)
612
+ expect(writeContract).not.toHaveBeenCalled()
613
+ })
614
+
615
+ it("rejects unsetting a system cell, which belongs to the engine", async () => {
616
+ const { client } = makeClient()
617
+ await expect(
618
+ sendArkivTransaction(client, { patches: [{ entityKey: ENTITY_KEY, unset: ["$payload"] }] }),
619
+ ).rejects.toThrow(InvalidAttributeNameError)
620
+ })
621
+
622
+ it("does not fetch a block height for a patch-only batch", async () => {
623
+ const { client } = makeClient()
624
+ await sendArkivTransaction(client, {
625
+ patches: [{ entityKey: ENTITY_KEY, set: { level: i32(1) } }],
626
+ })
627
+ // A patch carries no expiry, so there is nothing to resolve against the chain.
628
+ expect(client.getBlockNumber).not.toHaveBeenCalled()
629
+ })
630
+ })
631
+
632
+ describe("failures that are not reverts", () => {
633
+ it("keeps an unrecognised error's message and the error itself as the cause", async () => {
634
+ const { client } = makeClient()
635
+ // The transaction was broadcast; it is waiting for the receipt that times out. What that error
636
+ // knows — not least the hash it was watching — is the only way to find out whether the patch
637
+ // landed, so summarising it away as "Transaction failed" would strand the caller.
638
+ const timeout = new Error("Timed out while waiting for transaction 0xabc to be confirmed.")
639
+ ;(client.waitForTransactionReceipt as ReturnType<typeof vi.fn>).mockRejectedValue(timeout)
640
+
641
+ const failure = await sendArkivTransaction(client, {
642
+ patches: [{ entityKey: ENTITY_KEY, set: { level: i32(1) } }],
643
+ }).catch((error: Error) => error)
644
+
645
+ expect(failure).toBeInstanceOf(EntityMutationError)
646
+ expect(failure.message).toContain("0xabc")
647
+ expect(failure.cause).toBe(timeout)
648
+ })
649
+ })
650
+
651
+ describe("a revert the node catches before there is a receipt", () => {
652
+ /** A decoded engine revert, wrapped the way viem delivers one out of `writeContract`. */
653
+ function preflightRevert(errorName: string, args: readonly unknown[]) {
654
+ const inner = new ContractFunctionRevertedError({ abi: [], functionName: "execute" })
655
+ Object.assign(inner, { data: { errorName, args } })
656
+ return new BaseError('The contract function "execute" reverted.', { cause: inner })
657
+ }
658
+
659
+ it("explains it, rather than leaving the explainer to the rarer post-receipt path", async () => {
660
+ // Most reverts never reach the receipt: the node rejects them during gas estimation, so
661
+ // `writeContract` throws and there is nothing to replay. Wiring the named-error decoder only
662
+ // into the replay path would explain the rare case and stay silent on the common one.
663
+ const { client } = makeClient()
664
+ ;(client.writeContract as ReturnType<typeof vi.fn>).mockRejectedValue(
665
+ preflightRevert("ReadOnlyEntity", [ENTITY_KEY]),
666
+ )
667
+
668
+ const failure = await sendArkivTransaction(client, {
669
+ patches: [{ entityKey: ENTITY_KEY, set: { level: i32(1) } }],
670
+ }).catch((error: Error) => error)
671
+
672
+ expect(failure).toBeInstanceOf(EntityMutationError)
673
+ expect(failure.message).toContain("readonly")
674
+ // viem's own rendering prints the decoded args positionally — the right data, none of the
675
+ // meaning — so the point is that it is *not* what surfaces.
676
+ expect(failure.message).not.toContain('The contract function "execute" reverted')
677
+ })
678
+
679
+ it("still names a bad attribute-name byte, which only ever fails at estimation", async () => {
680
+ const { client } = makeClient()
681
+ ;(client.writeContract as ReturnType<typeof vi.fn>).mockRejectedValue(
682
+ preflightRevert("Ident32InvalidByte", [2n, "0x2f"]),
683
+ )
684
+
685
+ const failure = await sendArkivTransaction(client, {
686
+ patches: [{ entityKey: ENTITY_KEY, set: { level: i32(1) } }],
687
+ }).catch((error: Error) => error)
688
+
689
+ expect(failure.message).toContain('"/"')
690
+ expect(failure.message).toContain("byte 2")
691
+ })
692
+
693
+ it("leaves an error it cannot decode to the existing handling", async () => {
694
+ const { client } = makeClient()
695
+ const opaque = new BaseError("nonce too low")
696
+ ;(client.writeContract as ReturnType<typeof vi.fn>).mockRejectedValue(opaque)
697
+
698
+ const failure = await sendArkivTransaction(client, {
699
+ patches: [{ entityKey: ENTITY_KEY, set: { level: i32(1) } }],
700
+ }).catch((error: Error) => error)
701
+
702
+ expect(failure.message).toContain("nonce too low")
703
+ expect(failure.cause).toBe(opaque)
61
704
  })
62
705
  })