@arkiv-network/sdk 0.6.8 → 0.7.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 (101) hide show
  1. package/README.md +52 -16
  2. package/dist/consts-Bz0LZQZY.js +7 -0
  3. package/dist/consts-Bz0LZQZY.js.map +1 -0
  4. package/dist/consts-CxT1-RXU.cjs +18 -0
  5. package/dist/entity-BhSXaB23.cjs +81 -0
  6. package/dist/{query-CO6f35AE.d.cts → entity-CN41IOrq.d.cts} +125 -59
  7. package/dist/entity-CN41IOrq.d.cts.map +1 -0
  8. package/dist/entity-DGfVyB-a.js +71 -0
  9. package/dist/entity-DGfVyB-a.js.map +1 -0
  10. package/dist/{query-BhdmAzYg.d.ts → entity-NnEVlePN.d.ts} +125 -59
  11. package/dist/entity-NnEVlePN.d.ts.map +1 -0
  12. package/dist/index-ClkpS6s_.d.ts +481 -0
  13. package/dist/index-ClkpS6s_.d.ts.map +1 -0
  14. package/dist/index-w4qop0Pe.d.cts +481 -0
  15. package/dist/index-w4qop0Pe.d.cts.map +1 -0
  16. package/dist/index.cjs +285 -233
  17. package/dist/index.d.cts +100 -6
  18. package/dist/index.d.cts.map +1 -1
  19. package/dist/index.d.ts +101 -7
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +280 -230
  22. package/dist/index.js.map +1 -1
  23. package/dist/payload-BpNUPOnY.js +13 -0
  24. package/dist/payload-BpNUPOnY.js.map +1 -0
  25. package/dist/{payload-Da9gJMG5.d.ts → payload-C2KHOUoj.d.ts} +1 -1
  26. package/dist/{payload-Da9gJMG5.d.ts.map → payload-C2KHOUoj.d.ts.map} +1 -1
  27. package/dist/{payload-vwU3Rc6P.cjs → payload-D8YLKcJj.cjs} +1 -18
  28. package/dist/query/index.cjs +11 -28
  29. package/dist/query/index.d.cts +3 -3
  30. package/dist/query/index.d.ts +3 -3
  31. package/dist/query/index.js +8 -28
  32. package/dist/query/index.js.map +1 -1
  33. package/dist/{queryBuilder-DonAg1aq.js → queryBuilder-BE_XhLLa.js} +303 -152
  34. package/dist/queryBuilder-BE_XhLLa.js.map +1 -0
  35. package/dist/{queryBuilder-DM-YO1Y7.cjs → queryBuilder-J37buGCi.cjs} +372 -149
  36. package/dist/types/index.cjs +4 -1
  37. package/dist/types/index.d.cts +2 -2
  38. package/dist/types/index.d.ts +2 -2
  39. package/dist/types/index.js +4 -1
  40. package/dist/utils/index.cjs +4 -10
  41. package/dist/utils/index.d.cts +0 -1
  42. package/dist/utils/index.d.cts.map +1 -1
  43. package/dist/utils/index.d.ts +1 -2
  44. package/dist/utils/index.d.ts.map +1 -1
  45. package/dist/utils/index.js +2 -3
  46. package/dist/utils/index.js.map +1 -1
  47. package/package.json +11 -15
  48. package/src/actions/public/buildQuery.ts +4 -0
  49. package/src/actions/public/query.ts +10 -0
  50. package/src/actions/public/subscribeEntityEvents.ts +25 -51
  51. package/src/actions/wallet/changeOwnership.ts +6 -6
  52. package/src/actions/wallet/createEntity.ts +19 -6
  53. package/src/actions/wallet/deleteEntity.ts +2 -3
  54. package/src/actions/wallet/extendEntity.ts +9 -5
  55. package/src/actions/wallet/mutateEntities.ts +11 -54
  56. package/src/actions/wallet/updateEntity.ts +15 -6
  57. package/src/clients/createPublicClient.test.ts +2 -0
  58. package/src/clients/decorators/arkivPublic.ts +67 -2
  59. package/src/clients/decorators/arkivWallet.ts +23 -1
  60. package/src/consts.ts +2 -1
  61. package/src/errors.ts +46 -0
  62. package/src/index.ts +0 -3
  63. package/src/query/engine.test.ts +45 -0
  64. package/src/query/engine.ts +28 -14
  65. package/src/query/index.ts +1 -0
  66. package/src/query/predicate.test.ts +43 -0
  67. package/src/query/predicate.ts +14 -10
  68. package/src/query/queryBuilder.test.ts +22 -0
  69. package/src/query/queryBuilder.ts +221 -106
  70. package/src/query/queryResult.ts +13 -7
  71. package/src/query/selectQueryBuilder.test.ts +213 -0
  72. package/src/query/selection.test.ts +120 -0
  73. package/src/query/selection.ts +116 -0
  74. package/src/types/attributes.ts +25 -1
  75. package/src/types/entity.ts +12 -2
  76. package/src/types/events.ts +14 -16
  77. package/src/types/index.ts +1 -0
  78. package/src/types/rpcSchema.ts +7 -2
  79. package/src/utils/arkivTransactions.test.ts +260 -0
  80. package/src/utils/arkivTransactions.ts +255 -92
  81. package/src/utils/entities.ts +17 -7
  82. package/src/utils/index.ts +0 -1
  83. package/src/utils/payload.ts +4 -2
  84. package/src/utils/validation.test.ts +106 -0
  85. package/src/utils/validation.ts +80 -0
  86. package/dist/accounts/index.cjs +0 -9
  87. package/dist/accounts/index.d.cts +0 -1
  88. package/dist/accounts/index.d.ts +0 -1
  89. package/dist/accounts/index.js +0 -1
  90. package/dist/payload-DKBYYjeA.js +0 -18
  91. package/dist/payload-DKBYYjeA.js.map +0 -1
  92. package/dist/query-BhdmAzYg.d.ts.map +0 -1
  93. package/dist/query-CO6f35AE.d.cts.map +0 -1
  94. package/dist/queryBuilder-BvoQXa_u.d.ts +0 -328
  95. package/dist/queryBuilder-BvoQXa_u.d.ts.map +0 -1
  96. package/dist/queryBuilder-DonAg1aq.js.map +0 -1
  97. package/dist/queryBuilder-zTsKXctT.d.cts +0 -328
  98. package/dist/queryBuilder-zTsKXctT.d.cts.map +0 -1
  99. package/src/accounts/index.ts +0 -1
  100. /package/dist/{types-H-TSmRpm.js → types-Ci4BRClw.js} +0 -0
  101. /package/dist/{types-o3zWarRp.cjs → types-a8gm_IaQ.cjs} +0 -0
@@ -0,0 +1,260 @@
1
+ import { describe, expect, it, vi } from "bun:test"
2
+ import { stringToBytes, toBytes, toHex } from "viem"
3
+ import type { ArkivClient } from "../clients/baseClient"
4
+ import { DuplicateAttributeError, InvalidAttributeError, InvalidExpirationError } from "../errors"
5
+ import { sendArkivTransaction } from "./arkivTransactions"
6
+
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
+ ]
20
+ }
21
+
22
+ function makeClient() {
23
+ const writeContract = vi.fn().mockResolvedValue("0xdeadbeef")
24
+ const waitForTransactionReceipt = vi.fn().mockResolvedValue({
25
+ status: "success",
26
+ transactionHash: "0xdeadbeef",
27
+ })
28
+ return {
29
+ client: {
30
+ account: { address: "0x1111111111111111111111111111111111111111" },
31
+ chain: { id: 1 },
32
+ getBlockNumber: vi.fn().mockResolvedValue(100n),
33
+ readContract: vi.fn().mockResolvedValue(0),
34
+ writeContract,
35
+ waitForTransactionReceipt,
36
+ } as unknown as ArkivClient,
37
+ writeContract,
38
+ }
39
+ }
40
+
41
+ const BASE_CREATE = {
42
+ payload: new Uint8Array([1, 2, 3]),
43
+ contentType: "application/octet-stream",
44
+ expiresIn: 3600,
45
+ }
46
+
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[] }[]
54
+ }
55
+
56
+ const validCreate = {
57
+ payload: new Uint8Array([1, 2, 3]),
58
+ contentType: "text/plain",
59
+ attributes: [{ key: "k", value: "v" as string | number }],
60
+ expiresIn: 1000,
61
+ }
62
+
63
+ describe("sendArkivTransaction expiration validation", () => {
64
+ it("sends a transaction for a valid create", async () => {
65
+ const { client, writeContract } = makeClient()
66
+ const result = await sendArkivTransaction(client, { creates: [validCreate] })
67
+ expect(writeContract).toHaveBeenCalledTimes(1)
68
+ expect(result.receipt.status).toBe("success")
69
+ })
70
+
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)
76
+ })
77
+
78
+ it("rejects when a create expiresIn is not an integer", async () => {
79
+ const { client } = makeClient()
80
+ await expect(
81
+ sendArkivTransaction(client, { creates: [{ ...validCreate, expiresIn: 51.5 }] }),
82
+ ).rejects.toThrow(InvalidExpirationError)
83
+ })
84
+
85
+ it("rejects when an extension expiresIn is not a multiple of the block time", async () => {
86
+ const { client } = makeClient()
87
+ await expect(
88
+ sendArkivTransaction(client, { extensions: [{ entityKey: "0x123", expiresIn: 999 }] }),
89
+ ).rejects.toThrow(InvalidExpirationError)
90
+ })
91
+ })
92
+
93
+ describe("sendArkivTransaction attribute validation", () => {
94
+ it("rejects when a numeric attribute value is not an integer", async () => {
95
+ const { client } = makeClient()
96
+ await expect(
97
+ sendArkivTransaction(client, {
98
+ creates: [{ ...validCreate, attributes: [{ key: "n", value: 1.5 }] }],
99
+ }),
100
+ ).rejects.toThrow(InvalidAttributeError)
101
+ })
102
+
103
+ it("accepts integer numeric attribute values", async () => {
104
+ const { client, writeContract } = makeClient()
105
+ await sendArkivTransaction(client, {
106
+ creates: [{ ...validCreate, attributes: [{ key: "n", value: 123 }] }],
107
+ })
108
+ expect(writeContract).toHaveBeenCalledTimes(1)
109
+ })
110
+
111
+ it("accepts string attribute values that look like non-integers", async () => {
112
+ const { client, writeContract } = makeClient()
113
+ await sendArkivTransaction(client, {
114
+ creates: [{ ...validCreate, attributes: [{ key: "n", value: "1.5" }] }],
115
+ })
116
+ expect(writeContract).toHaveBeenCalledTimes(1)
117
+ })
118
+ })
119
+
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" }])
123
+
124
+ expect(attr.name).toBe(toHex("name", { size: 32 }))
125
+ expect(attr.valueType).toBe(2)
126
+ expect(attr.value).toEqual(encodeStringTo128("hello world"))
127
+ })
128
+
129
+ it("encodes an empty string as STRING with all-zero value", async () => {
130
+ const [attr] = await captureAttributes([{ key: "tag", value: "" }])
131
+
132
+ expect(attr.valueType).toBe(2)
133
+ expect(attr.value).toEqual([ZERO_32, ZERO_32, ZERO_32, ZERO_32])
134
+ })
135
+
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 }])
139
+
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])
143
+ })
144
+
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
+ }
153
+
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)
158
+ })
159
+
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 }])
162
+
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])
166
+ })
167
+
168
+ it("encodes zero as UINT", async () => {
169
+ const [attr] = await captureAttributes([{ key: "n", value: 0 }])
170
+
171
+ expect(attr.valueType).toBe(1)
172
+ expect(attr.value).toEqual([ZERO_32, ZERO_32, ZERO_32, ZERO_32])
173
+ })
174
+
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 }])
178
+
179
+ expect(attr.valueType).toBe(1)
180
+ expect(attr.value[0]).toBe(toHex(BigInt(large), { size: 32 }))
181
+ })
182
+
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 },
189
+ ])
190
+
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 }) })
195
+ })
196
+
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 }])
200
+
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
205
+ })
206
+ })
207
+
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
+ ])
215
+
216
+ expect(attrs.map((a) => a.name)).toEqual([
217
+ toHex("score", { size: 32 }),
218
+ toHex("status", { size: 32 }),
219
+ toHex("tag", { size: 32 }),
220
+ ])
221
+ })
222
+
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 },
227
+ ])
228
+
229
+ expect(attrs.map((a) => a.name)).toEqual([
230
+ toHex("ab", { size: 32 }),
231
+ toHex("abc", { size: 32 }),
232
+ ])
233
+ })
234
+
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"])
242
+ })
243
+
244
+ it("rejects duplicate attribute keys", async () => {
245
+ const { client } = makeClient()
246
+ await expect(
247
+ sendArkivTransaction(client, {
248
+ creates: [
249
+ {
250
+ ...BASE_CREATE,
251
+ attributes: [
252
+ { key: "tag", value: "one" },
253
+ { key: "tag", value: "two" },
254
+ ],
255
+ },
256
+ ],
257
+ }),
258
+ ).rejects.toThrow(DuplicateAttributeError)
259
+ })
260
+ })
@@ -1,4 +1,17 @@
1
- import { type Hex, hexToString, TransactionExecutionError, toBytes, toHex, toRlp } from "viem"
1
+ import {
2
+ type Address,
3
+ ContractFunctionExecutionError,
4
+ ContractFunctionRevertedError,
5
+ encodePacked,
6
+ type Hex,
7
+ keccak256,
8
+ parseAbi,
9
+ stringToBytes,
10
+ TransactionExecutionError,
11
+ type TransactionReceipt,
12
+ toBytes,
13
+ toHex,
14
+ } from "viem"
2
15
  import type { ChangeOwnershipParameters } from "../actions/wallet/changeOwnership"
3
16
  import type { CreateEntityParameters } from "../actions/wallet/createEntity"
4
17
  import type { DeleteEntityParameters } from "../actions/wallet/deleteEntity"
@@ -7,140 +20,290 @@ import type { UpdateEntityParameters } from "../actions/wallet/updateEntity"
7
20
  import type { ArkivClient } from "../clients/baseClient"
8
21
  import type { WalletArkivClient } from "../clients/createWalletClient"
9
22
  import { ARKIV_ADDRESS, BLOCK_TIME } from "../consts"
10
- import { EntityMutationError } from "../errors"
23
+ import { DuplicateAttributeError, EntityMutationError, InvalidContentTypeError } from "../errors"
11
24
  import type { TxParams } from "../types"
12
- import { compress } from "./compression"
25
+ import { EntityOperationType } from "../types/entity"
26
+ import { RpcAttributeValueType as AttributeValueType } from "../types/rpcSchema"
27
+
13
28
  import { getLogger } from "./logger"
29
+ import { isEntityKey, validateAttribute, validateExpiresIn } from "./validation"
14
30
 
15
31
  const logger = getLogger("utils:arkiv-transactions")
16
32
 
17
- export function opsToTxData({
18
- creates,
19
- updates,
20
- deletes,
21
- extensions,
22
- ownershipChanges,
23
- }: {
24
- creates?: CreateEntityParameters[]
25
- updates?: UpdateEntityParameters[]
26
- deletes?: DeleteEntityParameters[]
27
- extensions?: ExtendEntityParameters[]
28
- ownershipChanges?: ChangeOwnershipParameters[]
29
- }) {
30
- function formatAttributes<T extends string | number | bigint | boolean>(attribute: {
31
- key: string
32
- value: T
33
- }): [Hex, Hex] {
34
- return [
35
- toHex(attribute.key),
36
- toHex(typeof attribute.value === "number" && attribute.value === 0 ? "" : attribute.value),
37
- ]
33
+ // RFC 2045 token chars, lowercase only. Rejects uppercase to prevent text/plain vs Text/Plain ambiguity.
34
+ const MIME_REGEX = /^[a-z][a-z0-9!#$&\-^_]*\/[a-z0-9][a-z0-9!#$&\-^_.+]*$/
35
+
36
+ function validateContentType(contentType: string): void {
37
+ if (!MIME_REGEX.test(contentType)) {
38
+ throw new InvalidContentTypeError(contentType)
38
39
  }
40
+ }
41
+
42
+ // Mime128 = struct { bytes32[4] data } (128-byte fixed MIME type container)
43
+ // Attribute = struct { bytes32 name, uint8 valueType, bytes32[4] value }
44
+ // BlockNumber = type BlockNumber is uint32
45
+ export const ENTITY_EXECUTE_ABI = parseAbi([
46
+ "function execute((uint8 operationType, bytes32 entityKey, bytes payload, (bytes32[4] data) contentType, (bytes32 name, uint8 valueType, bytes32[4] value)[] attributes, uint32 expiresIn, address newOwner)[] ops) external",
47
+ ])
48
+
49
+ export const ENTITY_OPERATION_EVENT_ABI = parseAbi([
50
+ "event EntityOperation(bytes32 indexed entityKey, uint8 indexed operationType, address indexed owner, uint32 expiresAt, bytes32 entityHash)",
51
+ ])
39
52
 
40
- const payload = [
41
- //creates
42
- (creates ?? []).map((item) => [
43
- toHex(Math.ceil(item.expiresIn / BLOCK_TIME)),
44
- toHex(item.contentType),
45
- toHex(item.payload),
46
- item.attributes
47
- .filter((attribute) => typeof attribute.value === "string")
48
- .map(formatAttributes),
49
- item.attributes
50
- .filter((attribute) => typeof attribute.value === "number")
51
- .map(formatAttributes),
52
- ]),
53
- //updates
54
- (updates ?? []).map((item) => [
55
- item.entityKey,
56
- toHex(item.contentType),
57
- toHex(Math.ceil(item.expiresIn / BLOCK_TIME)),
58
- toHex(item.payload),
59
- item.attributes
60
- .filter((attribute) => typeof attribute.value === "string")
61
- .map(formatAttributes),
62
- item.attributes
63
- .filter((attribute) => typeof attribute.value === "number")
64
- .map(formatAttributes),
65
- ]),
66
- //deletes
67
- (deletes ?? []).map((item) => item.entityKey),
68
- //extends
69
- (extensions ?? []).map((item) => [
70
- item.entityKey,
71
- toHex(Math.ceil(item.expiresIn / BLOCK_TIME)),
72
- ]),
73
- //ownershipChanges TODO
74
- (ownershipChanges ?? []).map((item) => [item.entityKey, item.newOwner]),
53
+ export const ENTITY_ERRORS_ABI = parseAbi([
54
+ "error EmptyBatch()",
55
+ "error AttributesNotSorted()",
56
+ "error InvalidValueType(bytes32 name, uint8 valueType)",
57
+ "error InvalidOpType(uint8 operationType)",
58
+ "error ExpiryInPast(uint32 expiresAt, uint32 currentBlock)",
59
+ "error TooManyAttributes(uint256 count, uint256 maxCount)",
60
+ "error EntityNotFound(bytes32 entityKey)",
61
+ "error NotOwner(bytes32 entityKey, address caller, address owner)",
62
+ "error EntityExpired(bytes32 entityKey, uint32 expiresAt)",
63
+ "error ExpiryNotExtended(bytes32 entityKey, uint32 newExpiresAt, uint32 currentExpiresAt)",
64
+ "error TransferToZeroAddress(bytes32 entityKey)",
65
+ "error TransferToSelf(bytes32 entityKey)",
66
+ "error EntityNotExpired(bytes32 entityKey, uint32 expiresAt)",
67
+ "error Ident32Empty()",
68
+ "error Ident32TooLong(uint256 length)",
69
+ "error Ident32InvalidByte(uint256 position, bytes1 value)",
70
+ "error MimeEmpty()",
71
+ "error MimeTooLong(uint256 length, uint256 maxLength)",
72
+ "error MimeInvalidByte(uint256 position, bytes1 value)",
73
+ "error MimeIncomplete()",
74
+ ])
75
+
76
+ const EXECUTE_ABI = [...ENTITY_EXECUTE_ABI, ...ENTITY_ERRORS_ABI]
77
+
78
+ const ENTITY_NONCE_ABI = parseAbi(["function nonces(address owner) view returns (uint32)"])
79
+
80
+ const ZERO_ADDRESS: Address = "0x0000000000000000000000000000000000000000"
81
+ const ZERO_32 = `0x${"00".repeat(32)}` as Hex
82
+ const EMPTY_BYTES128 = [ZERO_32, ZERO_32, ZERO_32, ZERO_32] as const
83
+
84
+ // Encode a string or bytes into a bytes32[4] (128-byte) container, left-aligned.
85
+ function encodeBytes128(data: Uint8Array): readonly [Hex, Hex, Hex, Hex] {
86
+ const padded = new Uint8Array(128)
87
+ padded.set(data.slice(0, 128))
88
+ return [
89
+ toHex(padded.slice(0, 32)),
90
+ toHex(padded.slice(32, 64)),
91
+ toHex(padded.slice(64, 96)),
92
+ toHex(padded.slice(96, 128)),
75
93
  ]
94
+ }
76
95
 
77
- logger("txData to send as RLP %o length %d", payload, payload.length)
96
+ // Mime128 struct: bytes32[4] data, string packed left-aligned into 128 bytes.
97
+ function encodeMime128(contentType: string): { data: readonly [Hex, Hex, Hex, Hex] } {
98
+ return { data: contentType ? encodeBytes128(stringToBytes(contentType)) : EMPTY_BYTES128 }
99
+ }
100
+
101
+ function encodeAttribute(attr: { key: string; value: Hex | string | number }) {
102
+ validateAttribute(attr)
103
+ const name = toHex(attr.key, { size: 32 })
104
+
105
+ if (typeof attr.value === "string") {
106
+ // Only exact 32-byte hex (an entity key) gets the EntityKey type; shorter or
107
+ // longer hex-looking strings are stored as plain strings so they round-trip
108
+ // unchanged and stay matchable by quoted string queries.
109
+ if (isEntityKey(attr.value)) {
110
+ return {
111
+ name,
112
+ valueType: AttributeValueType.EntityKey,
113
+ value: [toHex(toBytes(attr.value), { size: 32 }), ZERO_32, ZERO_32, ZERO_32] as const,
114
+ }
115
+ }
116
+ return {
117
+ name,
118
+ valueType: AttributeValueType.String,
119
+ // stringToBytes, not toBytes: toBytes hex-decodes hex-looking strings, which
120
+ // would corrupt string values like "0xab" instead of UTF-8 encoding them
121
+ value: encodeBytes128(stringToBytes(attr.value)),
122
+ }
123
+ }
78
124
 
79
- return toRlp(payload)
125
+ return {
126
+ name,
127
+ valueType: AttributeValueType.Uint,
128
+ value: [toHex(BigInt(attr.value), { size: 32 }), ZERO_32, ZERO_32, ZERO_32] as const,
129
+ }
80
130
  }
81
131
 
82
- export async function sendArkivTransaction(client: ArkivClient, data: Hex, txParams?: TxParams) {
132
+ // The registry requires attributes strictly ascending by their bytes32 name — the
133
+ // canonical serialization hashes into the state root, and strict ordering doubles as
134
+ // the uniqueness check (AttributesNotSorted). Sort here so callers can pass any order;
135
+ // duplicates would revert on-chain, so reject them client-side with a clear error.
136
+ function encodeAttributes(attrs: { key: string; value: Hex | string | number }[]) {
137
+ const encoded = attrs
138
+ .map((attr) => ({ key: attr.key, ...encodeAttribute(attr) }))
139
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
140
+ for (let i = 1; i < encoded.length; i++) {
141
+ if (encoded[i].name === encoded[i - 1].name) {
142
+ throw new DuplicateAttributeError(encoded[i].key)
143
+ }
144
+ }
145
+ return encoded.map(({ key: _, ...attr }) => attr)
146
+ }
147
+
148
+ function toBTL(expiresIn: number): number {
149
+ validateExpiresIn(expiresIn)
150
+ return Math.ceil(expiresIn / BLOCK_TIME)
151
+ }
152
+
153
+ // entityKey = keccak256(chainId || registryAddress || ownerAddress || nonce)
154
+ function deriveEntityKey(chainId: number, owner: Address, nonce: bigint): Hex {
155
+ return keccak256(
156
+ encodePacked(
157
+ ["uint256", "address", "address", "uint32"],
158
+ [BigInt(chainId), ARKIV_ADDRESS, owner, Number(nonce)],
159
+ ),
160
+ )
161
+ }
162
+
163
+ export type SendArkivTransactionResult = {
164
+ receipt: TransactionReceipt
165
+ createdEntityKeys: Hex[]
166
+ }
167
+
168
+ export async function sendArkivTransaction(
169
+ client: ArkivClient,
170
+ ops: {
171
+ creates?: CreateEntityParameters[]
172
+ updates?: UpdateEntityParameters[]
173
+ deletes?: DeleteEntityParameters[]
174
+ extensions?: ExtendEntityParameters[]
175
+ ownershipChanges?: ChangeOwnershipParameters[]
176
+ },
177
+ txParams?: TxParams,
178
+ ): Promise<SendArkivTransactionResult> {
83
179
  if (!client.account) throw new Error("Account required")
180
+ if (!client.chain) throw new Error("Chain required")
84
181
  const walletClient = client as WalletArkivClient
85
182
 
86
- logger("Sending transaction %o", {
87
- account: client.account,
88
- chain: client.chain,
89
- to: ARKIV_ADDRESS,
90
- value: 0n,
91
- data,
92
- ...txParams,
93
- })
183
+ const { creates, updates, deletes, extensions, ownershipChanges } = ops
184
+ const owner = client.account.address as Address
185
+
186
+ const ownerNonce: bigint = creates?.length
187
+ ? BigInt(
188
+ await walletClient.readContract({
189
+ address: ARKIV_ADDRESS,
190
+ abi: ENTITY_NONCE_ABI,
191
+ functionName: "nonces",
192
+ args: [owner],
193
+ }),
194
+ )
195
+ : 0n
196
+
197
+ const createdEntityKeys: Hex[] = (creates ?? []).map((_, i) =>
198
+ deriveEntityKey(client.chain!.id, owner, ownerNonce + BigInt(i)),
199
+ )
200
+
201
+ for (const item of creates ?? []) validateContentType(item.contentType)
202
+ for (const item of updates ?? []) validateContentType(item.contentType)
203
+
204
+ const operations = [
205
+ ...(creates ?? []).map((item, i) => ({
206
+ operationType: EntityOperationType.Create,
207
+ entityKey: createdEntityKeys[i],
208
+ payload: toHex(item.payload),
209
+ contentType: encodeMime128(item.contentType),
210
+ attributes: encodeAttributes(item.attributes),
211
+ expiresIn: toBTL(item.expiresIn),
212
+ newOwner: ZERO_ADDRESS,
213
+ })),
214
+ ...(updates ?? []).map((item) => ({
215
+ operationType: EntityOperationType.Update,
216
+ entityKey: item.entityKey,
217
+ payload: toHex(item.payload),
218
+ contentType: encodeMime128(item.contentType),
219
+ attributes: encodeAttributes(item.attributes),
220
+ expiresIn: 0, // contract ignores expiresAt on UPDATE
221
+ newOwner: ZERO_ADDRESS,
222
+ })),
223
+ ...(deletes ?? []).map((item) => ({
224
+ operationType: EntityOperationType.Delete,
225
+ entityKey: item.entityKey,
226
+ payload: "0x" as Hex,
227
+ contentType: encodeMime128(""),
228
+ attributes: [] as never[],
229
+ expiresIn: 0,
230
+ newOwner: ZERO_ADDRESS,
231
+ })),
232
+ ...(extensions ?? []).map((item) => ({
233
+ operationType: EntityOperationType.Extend,
234
+ entityKey: item.entityKey,
235
+ payload: "0x" as Hex,
236
+ contentType: encodeMime128(""),
237
+ attributes: [] as never[],
238
+ expiresIn: toBTL(item.expiresIn),
239
+ newOwner: ZERO_ADDRESS,
240
+ })),
241
+ ...(ownershipChanges ?? []).map((item) => ({
242
+ operationType: EntityOperationType.Transfer,
243
+ entityKey: item.entityKey,
244
+ payload: "0x" as Hex,
245
+ contentType: encodeMime128(""),
246
+ attributes: [] as never[],
247
+ expiresIn: 0,
248
+ newOwner: item.newOwner as Address,
249
+ })),
250
+ ]
251
+
252
+ logger("Sending execute with %d operations %s", operations.length, JSON.stringify(operations))
94
253
 
95
254
  try {
96
- const compressed = await compress(toBytes(data))
97
- const txHash = await walletClient.sendTransaction({
255
+ const txHash = await walletClient.writeContract({
256
+ address: ARKIV_ADDRESS,
257
+ abi: EXECUTE_ABI,
258
+ functionName: "execute",
259
+ args: [operations],
98
260
  account: client.account,
99
261
  chain: client.chain,
100
- to: ARKIV_ADDRESS,
101
- value: 0n,
102
- data: toHex(compressed),
103
262
  ...txParams,
104
263
  })
105
264
 
106
265
  const receipt = await walletClient.waitForTransactionReceipt({ hash: txHash })
107
266
  logger("Tx receipt %o", receipt)
267
+
108
268
  if (receipt.status === "reverted") {
109
269
  try {
110
- const callResult = await walletClient.call({
270
+ await walletClient.simulateContract({
271
+ address: ARKIV_ADDRESS,
272
+ abi: EXECUTE_ABI,
273
+ functionName: "execute",
274
+ args: [operations],
111
275
  account: client.account,
112
- to: ARKIV_ADDRESS,
113
- value: 0n,
114
- data: toHex(compressed),
115
- ...txParams,
276
+ chain: client.chain,
116
277
  })
117
- throw new EntityMutationError(
118
- `Transaction ${receipt.transactionHash} reverted. Please make sure the data for entities doesn't contain invalid characters or invalid data types.
119
- Reason: ${callResult.data ? hexToString(callResult.data) : "No reason provided by backend."}`,
120
- )
121
278
  } catch (err) {
122
- const error = err as { cause?: { details?: string } }
123
- const reason = error.cause?.details ?? "No reason provided by backend."
124
- logger(
125
- "Error while calling to get more details about reverted transaction. Reason: %s",
126
- reason,
127
- )
279
+ const error = err as { shortMessage?: string; cause?: { details?: string } }
280
+ const reason =
281
+ error.shortMessage ?? error.cause?.details ?? "No reason provided by backend."
128
282
  throw new EntityMutationError(
129
283
  `Transaction ${receipt.transactionHash} reverted. Reason: ${reason}`,
130
284
  )
131
285
  }
286
+ throw new EntityMutationError(
287
+ `Transaction ${receipt.transactionHash} reverted. No reason provided by backend.`,
288
+ )
132
289
  }
133
290
 
134
- return receipt
291
+ return { receipt, createdEntityKeys }
135
292
  } catch (error) {
136
293
  let message = "Transaction failed"
137
294
  if (error instanceof TransactionExecutionError) {
138
295
  message += `: ${error.details}`
296
+ } else if (error instanceof ContractFunctionExecutionError) {
297
+ logger("Contract function execution error data:", error.shortMessage)
298
+ if (error.cause instanceof ContractFunctionRevertedError) {
299
+ message += `: ${error.cause.message}`
300
+ } else {
301
+ message += ": Execution error without revert data"
302
+ }
139
303
  } else if (error instanceof EntityMutationError) {
140
304
  throw error
141
305
  }
142
306
 
143
- logger("%s Detailed error stack: %o", message, error)
144
307
  throw new EntityMutationError(message)
145
308
  }
146
309
  }