@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.
- package/README.md +52 -16
- package/dist/consts-Bz0LZQZY.js +7 -0
- package/dist/consts-Bz0LZQZY.js.map +1 -0
- package/dist/consts-CxT1-RXU.cjs +18 -0
- package/dist/entity-BhSXaB23.cjs +81 -0
- package/dist/{query-CO6f35AE.d.cts → entity-CN41IOrq.d.cts} +125 -59
- package/dist/entity-CN41IOrq.d.cts.map +1 -0
- package/dist/entity-DGfVyB-a.js +71 -0
- package/dist/entity-DGfVyB-a.js.map +1 -0
- package/dist/{query-BhdmAzYg.d.ts → entity-NnEVlePN.d.ts} +125 -59
- package/dist/entity-NnEVlePN.d.ts.map +1 -0
- package/dist/index-ClkpS6s_.d.ts +481 -0
- package/dist/index-ClkpS6s_.d.ts.map +1 -0
- package/dist/index-w4qop0Pe.d.cts +481 -0
- package/dist/index-w4qop0Pe.d.cts.map +1 -0
- package/dist/index.cjs +285 -233
- package/dist/index.d.cts +100 -6
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +101 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +280 -230
- package/dist/index.js.map +1 -1
- package/dist/payload-BpNUPOnY.js +13 -0
- package/dist/payload-BpNUPOnY.js.map +1 -0
- package/dist/{payload-Da9gJMG5.d.ts → payload-C2KHOUoj.d.ts} +1 -1
- package/dist/{payload-Da9gJMG5.d.ts.map → payload-C2KHOUoj.d.ts.map} +1 -1
- package/dist/{payload-vwU3Rc6P.cjs → payload-D8YLKcJj.cjs} +1 -18
- package/dist/query/index.cjs +11 -28
- package/dist/query/index.d.cts +3 -3
- package/dist/query/index.d.ts +3 -3
- package/dist/query/index.js +8 -28
- package/dist/query/index.js.map +1 -1
- package/dist/{queryBuilder-DonAg1aq.js → queryBuilder-BE_XhLLa.js} +303 -152
- package/dist/queryBuilder-BE_XhLLa.js.map +1 -0
- package/dist/{queryBuilder-DM-YO1Y7.cjs → queryBuilder-J37buGCi.cjs} +372 -149
- package/dist/types/index.cjs +4 -1
- package/dist/types/index.d.cts +2 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js +4 -1
- package/dist/utils/index.cjs +4 -10
- package/dist/utils/index.d.cts +0 -1
- package/dist/utils/index.d.cts.map +1 -1
- package/dist/utils/index.d.ts +1 -2
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -3
- package/dist/utils/index.js.map +1 -1
- package/package.json +11 -15
- package/src/actions/public/buildQuery.ts +4 -0
- package/src/actions/public/query.ts +10 -0
- package/src/actions/public/subscribeEntityEvents.ts +25 -51
- package/src/actions/wallet/changeOwnership.ts +6 -6
- package/src/actions/wallet/createEntity.ts +19 -6
- package/src/actions/wallet/deleteEntity.ts +2 -3
- package/src/actions/wallet/extendEntity.ts +9 -5
- package/src/actions/wallet/mutateEntities.ts +11 -54
- package/src/actions/wallet/updateEntity.ts +15 -6
- package/src/clients/createPublicClient.test.ts +2 -0
- package/src/clients/decorators/arkivPublic.ts +67 -2
- package/src/clients/decorators/arkivWallet.ts +23 -1
- package/src/consts.ts +2 -1
- package/src/errors.ts +46 -0
- package/src/index.ts +0 -3
- package/src/query/engine.test.ts +45 -0
- package/src/query/engine.ts +28 -14
- package/src/query/index.ts +1 -0
- package/src/query/predicate.test.ts +43 -0
- package/src/query/predicate.ts +14 -10
- package/src/query/queryBuilder.test.ts +22 -0
- package/src/query/queryBuilder.ts +221 -106
- package/src/query/queryResult.ts +13 -7
- package/src/query/selectQueryBuilder.test.ts +213 -0
- package/src/query/selection.test.ts +120 -0
- package/src/query/selection.ts +116 -0
- package/src/types/attributes.ts +25 -1
- package/src/types/entity.ts +12 -2
- package/src/types/events.ts +14 -16
- package/src/types/index.ts +1 -0
- package/src/types/rpcSchema.ts +7 -2
- package/src/utils/arkivTransactions.test.ts +260 -0
- package/src/utils/arkivTransactions.ts +255 -92
- package/src/utils/entities.ts +17 -7
- package/src/utils/index.ts +0 -1
- package/src/utils/payload.ts +4 -2
- package/src/utils/validation.test.ts +106 -0
- package/src/utils/validation.ts +80 -0
- package/dist/accounts/index.cjs +0 -9
- package/dist/accounts/index.d.cts +0 -1
- package/dist/accounts/index.d.ts +0 -1
- package/dist/accounts/index.js +0 -1
- package/dist/payload-DKBYYjeA.js +0 -18
- package/dist/payload-DKBYYjeA.js.map +0 -1
- package/dist/query-BhdmAzYg.d.ts.map +0 -1
- package/dist/query-CO6f35AE.d.cts.map +0 -1
- package/dist/queryBuilder-BvoQXa_u.d.ts +0 -328
- package/dist/queryBuilder-BvoQXa_u.d.ts.map +0 -1
- package/dist/queryBuilder-DonAg1aq.js.map +0 -1
- package/dist/queryBuilder-zTsKXctT.d.cts +0 -328
- package/dist/queryBuilder-zTsKXctT.d.cts.map +0 -1
- package/src/accounts/index.ts +0 -1
- /package/dist/{types-H-TSmRpm.js → types-Ci4BRClw.js} +0 -0
- /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 {
|
|
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 {
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
97
|
-
|
|
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
|
-
|
|
270
|
+
await walletClient.simulateContract({
|
|
271
|
+
address: ARKIV_ADDRESS,
|
|
272
|
+
abi: EXECUTE_ABI,
|
|
273
|
+
functionName: "execute",
|
|
274
|
+
args: [operations],
|
|
111
275
|
account: client.account,
|
|
112
|
-
|
|
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 =
|
|
124
|
-
|
|
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
|
}
|