@arkiv-network/sdk 0.7.0-dev.1 → 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 +22 -60
  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 +676 -375
  62. package/dist/index.d.cts +247 -208
  63. package/dist/index.d.cts.map +1 -1
  64. package/dist/index.d.ts +247 -208
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +626 -368
  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 -150
  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 -138
  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 +3 -3
  87. package/dist/types/index.d.cts +7 -2
  88. package/dist/types/index.d.ts +7 -2
  89. package/dist/types/index.js +3 -3
  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 +2 -2
  103. package/src/actions/wallet/createEntity.ts +99 -24
  104. package/src/actions/wallet/extendEntity.ts +37 -13
  105. package/src/actions/wallet/mutateEntities.test.ts +76 -0
  106. package/src/actions/wallet/mutateEntities.ts +19 -21
  107. package/src/actions/wallet/patchEntity.ts +90 -0
  108. package/src/attr/attributes.test.ts +120 -0
  109. package/src/attr/attributes.ts +190 -0
  110. package/src/attr/codec.test.ts +162 -0
  111. package/src/attr/codec.ts +220 -0
  112. package/src/attr/decimal.ts +86 -0
  113. package/src/attr/errors.ts +145 -0
  114. package/src/attr/index.ts +50 -0
  115. package/src/attr/names.ts +106 -0
  116. package/src/attr/types.ts +150 -0
  117. package/src/attr/values.test.ts +271 -0
  118. package/src/attr/values.ts +502 -0
  119. package/src/chains/cheesecake.ts +22 -0
  120. package/src/chains/index.ts +1 -2
  121. package/src/clients/createPublicClient.test.ts +3 -5
  122. package/src/clients/createPublicClient.ts +5 -4
  123. package/src/clients/createWalletClient.test.ts +2 -2
  124. package/src/clients/createWalletClient.ts +13 -7
  125. package/src/clients/decorators/arkivPublic.ts +160 -139
  126. package/src/clients/decorators/arkivWallet.ts +86 -94
  127. package/src/entity/create.test.ts +184 -0
  128. package/src/entity/errors.ts +38 -0
  129. package/src/entity/events.ts +16 -0
  130. package/src/entity/expiry.test.ts +195 -0
  131. package/src/entity/expiry.ts +181 -0
  132. package/src/entity/flags.ts +65 -0
  133. package/src/entity/index.ts +14 -0
  134. package/src/entity/key.ts +68 -0
  135. package/src/entity/operations.ts +134 -0
  136. package/src/entity/params.ts +15 -0
  137. package/src/errors.ts +26 -49
  138. package/src/index.ts +6 -0
  139. package/src/query/engine.ts +59 -139
  140. package/src/query/errors.ts +151 -0
  141. package/src/query/expression.test.ts +286 -0
  142. package/src/query/expression.ts +493 -0
  143. package/src/query/index.ts +40 -4
  144. package/src/query/injection.test.ts +151 -0
  145. package/src/query/queryBuilder.test.ts +322 -637
  146. package/src/query/queryBuilder.ts +126 -359
  147. package/src/query/queryResult.ts +54 -51
  148. package/src/query/selection.test.ts +48 -98
  149. package/src/query/selection.ts +77 -65
  150. package/src/types/entity.test.ts +44 -155
  151. package/src/types/entity.ts +60 -52
  152. package/src/types/events.ts +59 -16
  153. package/src/types/index.ts +20 -17
  154. package/src/types/rpcSchema.ts +69 -37
  155. package/src/utils/arkivTransactions.test.ts +600 -155
  156. package/src/utils/arkivTransactions.ts +181 -190
  157. package/src/utils/chains.test.ts +21 -24
  158. package/src/utils/compression.ts +1 -1
  159. package/src/utils/entities.test.ts +62 -0
  160. package/src/utils/entities.ts +109 -37
  161. package/src/utils/expirationTime.ts +114 -10
  162. package/src/utils/logger.ts +2 -2
  163. package/src/utils/revert.test.ts +99 -0
  164. package/src/utils/revert.ts +145 -0
  165. package/dist/chains-BifaY04V.cjs +0 -108
  166. package/dist/chains-CrtkVbBh.js +0 -86
  167. package/dist/chains-CrtkVbBh.js.map +0 -1
  168. package/dist/consts-Bz0LZQZY.js +0 -7
  169. package/dist/consts-Bz0LZQZY.js.map +0 -1
  170. package/dist/consts-CxT1-RXU.cjs +0 -18
  171. package/dist/entity-BhSXaB23.cjs +0 -81
  172. package/dist/entity-CN41IOrq.d.cts +0 -401
  173. package/dist/entity-CN41IOrq.d.cts.map +0 -1
  174. package/dist/entity-DGfVyB-a.js +0 -71
  175. package/dist/entity-DGfVyB-a.js.map +0 -1
  176. package/dist/entity-NnEVlePN.d.ts +0 -401
  177. package/dist/entity-NnEVlePN.d.ts.map +0 -1
  178. package/dist/index-ClkpS6s_.d.ts +0 -481
  179. package/dist/index-ClkpS6s_.d.ts.map +0 -1
  180. package/dist/index-w4qop0Pe.d.cts +0 -481
  181. package/dist/index-w4qop0Pe.d.cts.map +0 -1
  182. package/dist/payload-BpNUPOnY.js +0 -13
  183. package/dist/payload-BpNUPOnY.js.map +0 -1
  184. package/dist/payload-C2KHOUoj.d.ts +0 -6
  185. package/dist/payload-C2KHOUoj.d.ts.map +0 -1
  186. package/dist/payload-CJN8fTvX.d.cts +0 -6
  187. package/dist/payload-CJN8fTvX.d.cts.map +0 -1
  188. package/dist/payload-D8YLKcJj.cjs +0 -23
  189. package/dist/query/index.js.map +0 -1
  190. package/dist/queryBuilder-BE_XhLLa.js +0 -735
  191. package/dist/queryBuilder-BE_XhLLa.js.map +0 -1
  192. package/dist/queryBuilder-J37buGCi.cjs +0 -865
  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 -93
  198. package/src/actions/wallet/updateEntity.ts +0 -59
  199. package/src/chains/braga.ts +0 -26
  200. package/src/chains/kaolin.ts +0 -29
  201. package/src/query/engine.test.ts +0 -571
  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 -213
  206. package/src/types/attributes.ts +0 -28
  207. package/src/utils/validation.test.ts +0 -106
  208. package/src/utils/validation.ts +0 -80
  209. /package/dist/{types-Ci4BRClw.js → types-Bx1EKe0v.js} +0 -0
  210. /package/dist/{types-a8gm_IaQ.cjs → types-CKPddlfS.cjs} +0 -0
@@ -1,260 +1,705 @@
1
1
  import { describe, expect, it, vi } from "bun:test"
2
- import { stringToBytes, toBytes, toHex } from "viem"
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"
3
24
  import type { ArkivClient } from "../clients/baseClient"
4
- import { DuplicateAttributeError, InvalidAttributeError, InvalidExpirationError } from "../errors"
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"
5
30
  import { sendArkivTransaction } from "./arkivTransactions"
31
+ import { ExpirationTime } from "./expirationTime"
6
32
 
7
- const ZERO_32 = `0x${"00".repeat(32)}`
8
-
9
- function encodeStringTo128(str: string): string[] {
10
- const padded = new Uint8Array(128)
11
- // stringToBytes, not toBytes — mirrors encodeAttribute: string values are always
12
- // UTF-8 encoded, never hex-decoded, even when they look like hex
13
- padded.set(stringToBytes(str).slice(0, 128))
14
- return [
15
- toHex(padded.slice(0, 32)),
16
- toHex(padded.slice(32, 64)),
17
- toHex(padded.slice(64, 96)),
18
- toHex(padded.slice(96, 128)),
19
- ]
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
20
107
  }
21
108
 
22
109
  function makeClient() {
23
110
  const writeContract = vi.fn().mockResolvedValue("0xdeadbeef")
24
- const waitForTransactionReceipt = vi.fn().mockResolvedValue({
111
+ const waitForTransactionReceipt = vi.fn(async () => ({
25
112
  status: "success",
26
113
  transactionHash: "0xdeadbeef",
27
- })
114
+ logs: emittedLogs(writeContract),
115
+ }))
116
+ const readContract = vi.fn().mockResolvedValue(OWNER_NONCE)
28
117
  return {
29
118
  client: {
30
- account: { address: "0x1111111111111111111111111111111111111111" },
119
+ account: { address: OWNER },
31
120
  chain: { id: 1 },
32
- getBlockNumber: vi.fn().mockResolvedValue(100n),
33
- readContract: vi.fn().mockResolvedValue(0),
121
+ getBlockNumber: vi.fn().mockResolvedValue(CURRENT_BLOCK),
122
+ readContract,
34
123
  writeContract,
35
124
  waitForTransactionReceipt,
36
125
  } as unknown as ArkivClient,
37
126
  writeContract,
127
+ waitForTransactionReceipt,
128
+ readContract,
38
129
  }
39
130
  }
40
131
 
41
- const BASE_CREATE = {
42
- payload: new Uint8Array([1, 2, 3]),
43
- contentType: "application/octet-stream",
44
- expiresIn: 3600,
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
+ }[]
45
138
  }
46
139
 
47
- async function captureAttributes(attributes: { key: string; value: string | number }[]) {
48
- const { client, writeContract } = makeClient()
49
- await sendArkivTransaction(client, {
50
- creates: [{ ...BASE_CREATE, attributes }],
51
- })
52
- const callArgs = writeContract.mock.calls[0][0]
53
- return callArgs.args[0][0].attributes as { name: string; valueType: number; value: string[] }[]
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]
54
152
  }
55
153
 
56
- const validCreate = {
154
+ const CONTENTS = {
57
155
  payload: new Uint8Array([1, 2, 3]),
58
- contentType: "text/plain",
59
- attributes: [{ key: "k", value: "v" as string | number }],
60
- expiresIn: 1000,
156
+ contentType: "application/octet-stream",
61
157
  }
62
158
 
63
- describe("sendArkivTransaction expiration validation", () => {
64
- it("sends a transaction for a valid create", async () => {
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 () => {
65
164
  const { client, writeContract } = makeClient()
66
165
  const result = await sendArkivTransaction(client, { creates: [validCreate] })
67
166
  expect(writeContract).toHaveBeenCalledTimes(1)
68
167
  expect(result.receipt.status).toBe("success")
69
168
  })
70
169
 
71
- it("rejects when a create expiresIn is not a multiple of the block time", async () => {
72
- const { client } = makeClient()
73
- await expect(
74
- sendArkivTransaction(client, { creates: [{ ...validCreate, expiresIn: 51 }] }),
75
- ).rejects.toThrow(InvalidExpirationError)
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
76
183
  })
77
184
 
78
- it("rejects when a create expiresIn is not an integer", async () => {
185
+ it("reports the block the entity will actually expire at", async () => {
79
186
  const { client } = makeClient()
80
- await expect(
81
- sendArkivTransaction(client, { creates: [{ ...validCreate, expiresIn: 51.5 }] }),
82
- ).rejects.toThrow(InvalidExpirationError)
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)
83
204
  })
84
205
 
85
- it("rejects when an extension expiresIn is not a multiple of the block time", async () => {
206
+ it("refuses a create with no expiry at all", async () => {
86
207
  const { client } = makeClient()
87
208
  await expect(
88
- sendArkivTransaction(client, { extensions: [{ entityKey: "0x123", expiresIn: 999 }] }),
89
- ).rejects.toThrow(InvalidExpirationError)
209
+ sendArkivTransaction(client, { creates: [{ ...CONTENTS } as never] }),
210
+ ).rejects.toThrow(InvalidExpiryError)
90
211
  })
91
- })
92
212
 
93
- describe("sendArkivTransaction attribute validation", () => {
94
- it("rejects when a numeric attribute value is not an integer", async () => {
95
- const { client } = makeClient()
213
+ it("catches a dead-on-arrival expiry before it costs gas", async () => {
214
+ const { client, writeContract } = makeClient()
96
215
  await expect(
97
216
  sendArkivTransaction(client, {
98
- creates: [{ ...validCreate, attributes: [{ key: "n", value: 1.5 }] }],
217
+ creates: [{ ...CONTENTS, expires: ExpirationTime.atBlock(CURRENT_BLOCK - 1n) }],
99
218
  }),
100
- ).rejects.toThrow(InvalidAttributeError)
219
+ ).rejects.toThrow(/dead on arrival/)
220
+ expect(writeContract).not.toHaveBeenCalled()
101
221
  })
102
222
 
103
- it("accepts integer numeric attribute values", async () => {
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 () => {
104
234
  const { client, writeContract } = makeClient()
105
- await sendArkivTransaction(client, {
106
- creates: [{ ...validCreate, attributes: [{ key: "n", value: 123 }] }],
235
+ const relative = await sendArkivTransaction(client, {
236
+ extensions: [{ entityKey: ENTITY_KEY, expires: DAY }],
107
237
  })
108
- expect(writeContract).toHaveBeenCalledTimes(1)
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)
109
251
  })
110
252
 
111
- it("accepts string attribute values that look like non-integers", async () => {
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 () => {
112
270
  const { client, writeContract } = makeClient()
113
271
  await sendArkivTransaction(client, {
114
- creates: [{ ...validCreate, attributes: [{ key: "n", value: "1.5" }] }],
272
+ extensions: [
273
+ { entityKey: ENTITY_KEY, expires: ExpirationTime.atDate(new Date(Date.now() + 3600_000)) },
274
+ ],
115
275
  })
116
- expect(writeContract).toHaveBeenCalledTimes(1)
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)
117
279
  })
118
280
  })
119
281
 
120
- describe("encodeAttribute", () => {
121
- it("encodes a plain string as STRING (valueType 2) left-aligned across 128 bytes", async () => {
122
- const [attr] = await captureAttributes([{ key: "name", value: "hello world" }])
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
+ })
123
290
 
124
- expect(attr.name).toBe(toHex("name", { size: 32 }))
125
- expect(attr.valueType).toBe(2)
126
- expect(attr.value).toEqual(encodeStringTo128("hello world"))
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)
127
295
  })
128
296
 
129
- it("encodes an empty string as STRING with all-zero value", async () => {
130
- const [attr] = await captureAttributes([{ key: "tag", value: "" }])
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)
131
301
 
132
- expect(attr.valueType).toBe(2)
133
- expect(attr.value).toEqual([ZERO_32, ZERO_32, ZERO_32, ZERO_32])
302
+ const { client: c2, writeContract: w2 } = makeClient()
303
+ await sendArkivTransaction(c2, { creates: [{ ...validCreate, salt: 0n }] })
304
+ expect(sentCreate(w2).salt).toBe(0n)
134
305
  })
135
306
 
136
- it("encodes a hex string as ENTITY_KEY (valueType 3) in first slot, rest zero", async () => {
137
- const entityKey = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
138
- const [attr] = await captureAttributes([{ key: "ref", value: entityKey }])
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
+ })
139
314
 
140
- expect(attr.name).toBe(toHex("ref", { size: 32 }))
141
- expect(attr.valueType).toBe(3)
142
- expect(attr.value).toEqual([entityKey, ZERO_32, ZERO_32, ZERO_32])
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)])
143
328
  })
144
329
 
145
- it("treats hex strings that are not 32 bytes as STRING, not ENTITY_KEY", async () => {
146
- // only exact entity keys get the hex type — everything else must round-trip
147
- // as a string and stay matchable by quoted string queries
148
- for (const value of ["0x", "0xab", "0x6186b0dba9652262942d5a465d49686eb560834c"]) {
149
- const [attr] = await captureAttributes([{ key: "x", value }])
150
- expect(attr.valueType).toBe(2)
151
- expect(attr.value).toEqual(encodeStringTo128(value))
152
- }
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()
337
+ })
153
338
 
154
- // the encoded bytes are the UTF-8 text, not a hex-decode: "0xab" is the
155
- // four characters 0, x, a, b
156
- const [attr] = await captureAttributes([{ key: "x", value: "0xab" }])
157
- expect(attr.value[0].startsWith("0x30786162")).toBe(true)
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)
158
358
  })
159
359
 
160
- it("encodes a number as UINT (valueType 1) big-endian in first slot, rest zero", async () => {
161
- const [attr] = await captureAttributes([{ key: "count", value: 42 }])
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
+ })
162
373
 
163
- expect(attr.name).toBe(toHex("count", { size: 32 }))
164
- expect(attr.valueType).toBe(1)
165
- expect(attr.value).toEqual([toHex(42n, { size: 32 }), ZERO_32, ZERO_32, ZERO_32])
374
+ const result = await sendArkivTransaction(client, { creates: [validCreate] })
375
+ expect(result.createdEntityKeys).toEqual([mintedKey(0)])
166
376
  })
167
377
 
168
- it("encodes zero as UINT", async () => {
169
- const [attr] = await captureAttributes([{ key: "n", value: 0 }])
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)
170
389
 
171
- expect(attr.valueType).toBe(1)
172
- expect(attr.value).toEqual([ZERO_32, ZERO_32, ZERO_32, ZERO_32])
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")
173
395
  })
396
+ })
174
397
 
175
- it("encodes a large number as UINT", async () => {
176
- const large = Number.MAX_SAFE_INTEGER
177
- const [attr] = await captureAttributes([{ key: "big", value: large }])
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
+ })
178
405
 
179
- expect(attr.valueType).toBe(1)
180
- expect(attr.value[0]).toBe(toHex(BigInt(large), { size: 32 }))
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)
181
426
  })
182
427
 
183
- it("encodes multiple mixed-type attributes preserving order", async () => {
184
- const entityKey = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
185
- const attrs = await captureAttributes([
186
- { key: "count", value: 7 },
187
- { key: "label", value: "test" },
188
- { key: "ref", value: entityKey },
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 }),
189
434
  ])
435
+ })
190
436
 
191
- expect(attrs).toHaveLength(3)
192
- expect(attrs[0]).toMatchObject({ valueType: 1, name: toHex("count", { size: 32 }) })
193
- expect(attrs[1]).toMatchObject({ valueType: 2, name: toHex("label", { size: 32 }) })
194
- expect(attrs[2]).toMatchObject({ valueType: 3, name: toHex("ref", { size: 32 }) })
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" })
195
444
  })
196
445
 
197
- it("encodes a UTF-8 string longer than 32 bytes spanning multiple chunks", async () => {
198
- const long = "a".repeat(40)
199
- const [attr] = await captureAttributes([{ key: "body", value: long }])
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]),
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
+ })
200
460
 
201
- expect(attr.valueType).toBe(2)
202
- const [chunk0, chunk1] = attr.value
203
- expect(chunk0).toBe(toHex(new Uint8Array(32).fill(0x61))) // 32 × 'a'
204
- expect(chunk1.startsWith("0x6161616161616161")).toBe(true) // next 8 × 'a', then zeros
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)
205
476
  })
206
- })
207
477
 
208
- describe("attribute sorting", () => {
209
- it("sorts attributes ascending by their bytes32 name before encoding", async () => {
210
- const attrs = await captureAttributes([
211
- { key: "tag", value: "zz" },
212
- { key: "status", value: "active" },
213
- { key: "score", value: 30 },
214
- ])
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
+ })
215
484
 
216
- expect(attrs.map((a) => a.name)).toEqual([
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([
217
494
  toHex("score", { size: 32 }),
218
495
  toHex("status", { size: 32 }),
219
496
  toHex("tag", { size: 32 }),
220
497
  ])
221
498
  })
499
+ })
222
500
 
223
- it("sorts a shorter key before a longer key sharing its prefix", async () => {
224
- const attrs = await captureAttributes([
225
- { key: "abc", value: 1 },
226
- { key: "ab", value: 2 },
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) },
227
519
  ])
520
+ })
228
521
 
229
- expect(attrs.map((a) => a.name)).toEqual([
230
- toHex("ab", { size: 32 }),
231
- toHex("abc", { size: 32 }),
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 }),
232
560
  ])
233
561
  })
234
562
 
235
- it("does not mutate the caller's attributes array", async () => {
236
- const input = [
237
- { key: "b", value: 1 },
238
- { key: "a", value: 2 },
239
- ]
240
- await captureAttributes(input)
241
- expect(input.map((a) => a.key)).toEqual(["b", "a"])
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"] }],
577
+ }),
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()
242
592
  })
243
593
 
244
- it("rejects duplicate attribute keys", async () => {
594
+ it("still validates a content type when one is given", async () => {
245
595
  const { client } = makeClient()
246
596
  await expect(
247
597
  sendArkivTransaction(client, {
248
- creates: [
249
- {
250
- ...BASE_CREATE,
251
- attributes: [
252
- { key: "tag", value: "one" },
253
- { key: "tag", value: "two" },
254
- ],
255
- },
256
- ],
598
+ patches: [{ entityKey: ENTITY_KEY, payload: new Uint8Array(), contentType: "Not A Mime" }],
257
599
  }),
258
- ).rejects.toThrow(DuplicateAttributeError)
600
+ ).rejects.toThrow(/Invalid content type/)
601
+ })
602
+
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[] }],
610
+ }),
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)
259
704
  })
260
705
  })