@arkiv-network/sdk 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +52 -16
  2. package/dist/chains/index.d.cts +25 -25
  3. package/dist/consts-AUoL-v42.cjs +18 -0
  4. package/dist/consts-BV4PR-de.js +7 -0
  5. package/dist/consts-BV4PR-de.js.map +1 -0
  6. package/dist/{query-CO6f35AE.d.cts → entity-B6g_EPm1.d.cts} +78 -31
  7. package/dist/entity-B6g_EPm1.d.cts.map +1 -0
  8. package/dist/{query-BhdmAzYg.d.ts → entity-qoLMAuuk.d.ts} +78 -31
  9. package/dist/entity-qoLMAuuk.d.ts.map +1 -0
  10. package/dist/index-DdspxNAA.d.ts +481 -0
  11. package/dist/index-DdspxNAA.d.ts.map +1 -0
  12. package/dist/index-Ds9dwqb5.d.cts +481 -0
  13. package/dist/index-Ds9dwqb5.d.cts.map +1 -0
  14. package/dist/index.cjs +63 -28
  15. package/dist/index.d.cts +90 -5
  16. package/dist/index.d.cts.map +1 -1
  17. package/dist/index.d.ts +91 -6
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +58 -21
  20. package/dist/index.js.map +1 -1
  21. package/dist/{payload-Da9gJMG5.d.ts → payload-B2kFJyah.d.ts} +1 -1
  22. package/dist/{payload-Da9gJMG5.d.ts.map → payload-B2kFJyah.d.ts.map} +1 -1
  23. package/dist/payload-Claf2jUO.js +13 -0
  24. package/dist/payload-Claf2jUO.js.map +1 -0
  25. package/dist/{payload-vwU3Rc6P.cjs → payload-DpA5PA-w.cjs} +0 -17
  26. package/dist/query/index.cjs +10 -28
  27. package/dist/query/index.d.cts +3 -3
  28. package/dist/query/index.d.ts +3 -3
  29. package/dist/query/index.js +7 -28
  30. package/dist/query/index.js.map +1 -1
  31. package/dist/{queryBuilder-DonAg1aq.js → queryBuilder-D2uwP9jI.js} +209 -89
  32. package/dist/queryBuilder-D2uwP9jI.js.map +1 -0
  33. package/dist/{queryBuilder-DM-YO1Y7.cjs → queryBuilder-UW2MCaX4.cjs} +237 -87
  34. package/dist/types/index.d.cts +1 -1
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/utils/index.cjs +4 -10
  37. package/dist/utils/index.d.cts +0 -1
  38. package/dist/utils/index.d.cts.map +1 -1
  39. package/dist/utils/index.d.ts +1 -2
  40. package/dist/utils/index.d.ts.map +1 -1
  41. package/dist/utils/index.js +2 -3
  42. package/dist/utils/index.js.map +1 -1
  43. package/package.json +11 -15
  44. package/src/actions/public/buildQuery.ts +4 -0
  45. package/src/actions/public/query.ts +10 -0
  46. package/src/actions/wallet/createEntity.ts +12 -2
  47. package/src/actions/wallet/extendEntity.ts +6 -1
  48. package/src/actions/wallet/updateEntity.ts +12 -2
  49. package/src/clients/createPublicClient.test.ts +2 -0
  50. package/src/clients/decorators/arkivPublic.ts +67 -2
  51. package/src/clients/decorators/arkivWallet.ts +18 -0
  52. package/src/errors.ts +18 -0
  53. package/src/index.ts +0 -3
  54. package/src/query/engine.ts +22 -13
  55. package/src/query/index.ts +1 -0
  56. package/src/query/predicate.test.ts +43 -0
  57. package/src/query/predicate.ts +14 -10
  58. package/src/query/queryBuilder.test.ts +22 -0
  59. package/src/query/queryBuilder.ts +221 -106
  60. package/src/query/queryResult.ts +13 -7
  61. package/src/query/selectQueryBuilder.test.ts +210 -0
  62. package/src/query/selection.test.ts +120 -0
  63. package/src/query/selection.ts +116 -0
  64. package/src/types/attributes.ts +12 -0
  65. package/src/utils/arkivTransactions.test.ts +62 -0
  66. package/src/utils/arkivTransactions.ts +36 -27
  67. package/src/utils/index.ts +0 -1
  68. package/src/utils/validation.test.ts +60 -0
  69. package/src/utils/validation.ts +32 -0
  70. package/dist/accounts/index.cjs +0 -9
  71. package/dist/accounts/index.d.cts +0 -1
  72. package/dist/accounts/index.d.ts +0 -1
  73. package/dist/accounts/index.js +0 -1
  74. package/dist/payload-DKBYYjeA.js +0 -18
  75. package/dist/payload-DKBYYjeA.js.map +0 -1
  76. package/dist/query-BhdmAzYg.d.ts.map +0 -1
  77. package/dist/query-CO6f35AE.d.cts.map +0 -1
  78. package/dist/queryBuilder-BvoQXa_u.d.ts +0 -328
  79. package/dist/queryBuilder-BvoQXa_u.d.ts.map +0 -1
  80. package/dist/queryBuilder-DonAg1aq.js.map +0 -1
  81. package/dist/queryBuilder-zTsKXctT.d.cts +0 -328
  82. package/dist/queryBuilder-zTsKXctT.d.cts.map +0 -1
  83. package/src/accounts/index.ts +0 -1
@@ -0,0 +1,210 @@
1
+ import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"
2
+ import type { ArkivClient } from "../clients/baseClient"
3
+ import type { Entity } from "../types/entity"
4
+ import * as entitiesUtils from "../utils/entities"
5
+ import * as engine from "./engine"
6
+ import { eq, gte } from "./predicate"
7
+ import { SelectQueryBuilder } from "./queryBuilder"
8
+ import { selectionToIncludeData } from "./selection"
9
+
10
+ describe("SelectQueryBuilder", () => {
11
+ let mockClient: ArkivClient
12
+ let mockProcessQuery: any
13
+
14
+ beforeEach(() => {
15
+ mockClient = {
16
+ request: jest.fn(),
17
+ } as unknown as ArkivClient
18
+
19
+ mockProcessQuery = jest.spyOn(engine, "processQuery")
20
+ jest.spyOn(entitiesUtils, "entityFromRpcResult")
21
+ })
22
+
23
+ afterEach(() => {
24
+ jest.restoreAllMocks()
25
+ })
26
+
27
+ test("select() with no argument forwards include-data for everything", async () => {
28
+ mockProcessQuery.mockResolvedValue({ data: [] })
29
+
30
+ await new SelectQueryBuilder(mockClient).where(eq("name", "test")).fetch()
31
+
32
+ expect(mockProcessQuery).toHaveBeenCalledWith(
33
+ mockClient,
34
+ expect.objectContaining({
35
+ predicates: [eq("name", "test")],
36
+ includeData: selectionToIncludeData(),
37
+ }),
38
+ )
39
+ })
40
+
41
+ test('select("*") forwards include-data for everything', async () => {
42
+ mockProcessQuery.mockResolvedValue({ data: [] })
43
+
44
+ await new SelectQueryBuilder(mockClient, "*").fetch()
45
+
46
+ expect(mockProcessQuery).toHaveBeenCalledWith(
47
+ mockClient,
48
+ expect.objectContaining({
49
+ includeData: selectionToIncludeData("*"),
50
+ }),
51
+ )
52
+ })
53
+
54
+ test("select({ owner: true, attributes: true }) forwards the resolved include-data", async () => {
55
+ mockProcessQuery.mockResolvedValue({ data: [] })
56
+
57
+ await new SelectQueryBuilder(mockClient, { owner: true, attributes: true }).fetch()
58
+
59
+ expect(mockProcessQuery).toHaveBeenCalledWith(
60
+ mockClient,
61
+ expect.objectContaining({
62
+ includeData: selectionToIncludeData({ owner: true, attributes: true }),
63
+ }),
64
+ )
65
+ })
66
+
67
+ test("select({ owner: true }) forwards narrowed include-data", async () => {
68
+ mockProcessQuery.mockResolvedValue({ data: [] })
69
+
70
+ await new SelectQueryBuilder(mockClient, { owner: true }).fetch()
71
+
72
+ const call = mockProcessQuery.mock.calls[0][1]
73
+ expect(call.includeData).toEqual(selectionToIncludeData({ owner: true }))
74
+ expect(call.includeData.owner).toBe(true)
75
+ expect(call.includeData.creator).toBe(false)
76
+ // key is opt-in: not selected here, so it is excluded
77
+ expect(call.includeData.key).toBe(false)
78
+ })
79
+
80
+ test("does not forward the with* boolean flags", async () => {
81
+ mockProcessQuery.mockResolvedValue({ data: [] })
82
+
83
+ await new SelectQueryBuilder(mockClient, { attributes: true }).fetch()
84
+
85
+ const call = mockProcessQuery.mock.calls[0][1]
86
+ expect(call.withAttributes).toBeUndefined()
87
+ expect(call.withMetadata).toBeUndefined()
88
+ expect(call.withPayload).toBeUndefined()
89
+ })
90
+
91
+ test("shares the filtering/ordering/pagination chain methods", async () => {
92
+ mockProcessQuery.mockResolvedValue({ data: [] })
93
+
94
+ const owner = "0x1234567890123456789012345678901234567890" as const
95
+ await new SelectQueryBuilder(mockClient, { attributes: true })
96
+ .where(eq("name", "test"))
97
+ .where(gte("age", 18))
98
+ .ownedBy(owner)
99
+ .createdBy(owner)
100
+ .orderBy("name", "string", "desc")
101
+ .limit(10)
102
+ .cursor("0xABC123")
103
+ .validAtBlock(123n)
104
+ .fetch()
105
+
106
+ expect(mockProcessQuery).toHaveBeenCalledWith(mockClient, {
107
+ predicates: [eq("name", "test"), gte("age", 18)],
108
+ limit: 10,
109
+ cursor: "0xABC123",
110
+ ownedBy: owner,
111
+ createdBy: owner,
112
+ orderBy: [{ name: "name", type: "string", desc: true }],
113
+ validAtBlock: 123n,
114
+ includeData: selectionToIncludeData({ attributes: true }),
115
+ })
116
+ })
117
+
118
+ test("count() ignores the selection and requests no data", async () => {
119
+ mockProcessQuery.mockResolvedValue({
120
+ data: [
121
+ { key: "0xabc", value: "data1" },
122
+ { key: "0xdef", value: "data2" },
123
+ ],
124
+ })
125
+
126
+ const count = await new SelectQueryBuilder(mockClient, "*")
127
+ .where(eq("status", "active"))
128
+ .count()
129
+
130
+ expect(count).toBe(2)
131
+ expect(mockProcessQuery).toHaveBeenCalledWith(mockClient, {
132
+ predicates: [eq("status", "active")],
133
+ limit: undefined,
134
+ cursor: undefined,
135
+ ownedBy: undefined,
136
+ createdBy: undefined,
137
+ orderBy: undefined,
138
+ validAtBlock: undefined,
139
+ withAttributes: false,
140
+ withMetadata: false,
141
+ withPayload: false,
142
+ })
143
+ })
144
+
145
+ describe("result entities", () => {
146
+ test("fetch returns flat Entity instances with the selected data and methods", async () => {
147
+ mockProcessQuery.mockResolvedValue({
148
+ data: [
149
+ {
150
+ key: "0xabc",
151
+ owner: "0xowner",
152
+ value: "0x68656c6c6f", // "hello"
153
+ stringAttributes: [{ key: "category", value: "docs" }],
154
+ numericAttributes: [{ key: "score", value: "0x2a" }],
155
+ },
156
+ ],
157
+ cursor: "",
158
+ blockNumber: "0x1",
159
+ })
160
+
161
+ const result = await new SelectQueryBuilder(mockClient, {
162
+ key: true,
163
+ owner: true,
164
+ attributes: true,
165
+ payload: true,
166
+ }).fetch()
167
+
168
+ expect(result.entities).toHaveLength(1)
169
+ const entity = result.entities[0] as unknown as Entity
170
+ // flat, backwards-compatible field access
171
+ expect(entity.key).toBe("0xabc")
172
+ expect(entity.owner).toBe("0xowner")
173
+ expect(entity.attributes).toEqual([
174
+ { key: "category", value: "docs" },
175
+ { key: "score", value: 42 },
176
+ ])
177
+ // entity methods are preserved on the result
178
+ expect(typeof entity.toText).toBe("function")
179
+ expect(entity.toText()).toBe("hello")
180
+ })
181
+
182
+ test("toJson decodes a JSON payload", async () => {
183
+ mockProcessQuery.mockResolvedValue({
184
+ data: [{ key: "0xabc", value: "0x7b2261223a317d" }], // {"a":1}
185
+ cursor: "",
186
+ blockNumber: "0x1",
187
+ })
188
+
189
+ const result = await new SelectQueryBuilder(mockClient, { payload: true }).fetch()
190
+ const entity = result.entities[0] as unknown as Entity
191
+ expect(entity.toJson()).toEqual({ a: 1 })
192
+ })
193
+
194
+ test("fields the RPC omits (because they were not selected) are undefined at runtime", async () => {
195
+ // The RPC honours includeData and returns only the key.
196
+ mockProcessQuery.mockResolvedValue({
197
+ data: [{ key: "0xabc" }],
198
+ cursor: "",
199
+ blockNumber: "0x1",
200
+ })
201
+
202
+ const result = await new SelectQueryBuilder(mockClient, { key: true }).fetch()
203
+ const entity = result.entities[0] as unknown as Entity
204
+ expect(entity.key).toBe("0xabc")
205
+ expect(entity.owner).toBeUndefined()
206
+ expect(entity.payload).toBeUndefined()
207
+ expect(entity.attributes).toEqual([])
208
+ })
209
+ })
210
+ })
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { selectionToIncludeData } from "./selection"
3
+
4
+ const ALL = {
5
+ key: true,
6
+ attributes: true,
7
+ payload: true,
8
+ contentType: true,
9
+ expiration: true,
10
+ owner: true,
11
+ creator: true,
12
+ createdAtBlock: true,
13
+ lastModifiedAtBlock: true,
14
+ transactionIndexInBlock: true,
15
+ operationIndexInTransaction: true,
16
+ }
17
+
18
+ const EMPTY = {
19
+ key: false,
20
+ attributes: false,
21
+ payload: false,
22
+ contentType: false,
23
+ expiration: false,
24
+ owner: false,
25
+ creator: false,
26
+ createdAtBlock: false,
27
+ lastModifiedAtBlock: false,
28
+ transactionIndexInBlock: false,
29
+ operationIndexInTransaction: false,
30
+ }
31
+
32
+ describe("selectionToIncludeData", () => {
33
+ test("select() with no argument selects everything", () => {
34
+ expect(selectionToIncludeData()).toEqual(ALL)
35
+ })
36
+
37
+ test('select("*") selects everything', () => {
38
+ expect(selectionToIncludeData("*")).toEqual(ALL)
39
+ })
40
+
41
+ test("empty object throws", () => {
42
+ // @ts-expect-error – select requires at least one field
43
+ expect(() => selectionToIncludeData({})).toThrow()
44
+ })
45
+
46
+ test("an all-false selection throws (no field is actually selected)", () => {
47
+ expect(() => selectionToIncludeData({ key: false })).toThrow()
48
+ expect(() => selectionToIncludeData({ owner: false, payload: false })).toThrow()
49
+ })
50
+
51
+ test("key: true selects only the key", () => {
52
+ expect(selectionToIncludeData({ key: true })).toEqual({ ...EMPTY, key: true })
53
+ })
54
+
55
+ test("key is opt-in like every other field", () => {
56
+ expect(selectionToIncludeData({ key: true }).key).toBe(true)
57
+ expect(selectionToIncludeData({ attributes: true }).key).toBe(false)
58
+ expect(selectionToIncludeData({ owner: true }).key).toBe(false)
59
+ })
60
+
61
+ test("attributes only (no key)", () => {
62
+ expect(selectionToIncludeData({ attributes: true })).toEqual({
63
+ ...EMPTY,
64
+ attributes: true,
65
+ })
66
+ })
67
+
68
+ test("payload only (no key)", () => {
69
+ expect(selectionToIncludeData({ payload: true })).toEqual({
70
+ ...EMPTY,
71
+ payload: true,
72
+ })
73
+ })
74
+
75
+ test("a single metadata field, selected flat (no key)", () => {
76
+ expect(selectionToIncludeData({ owner: true })).toEqual({
77
+ ...EMPTY,
78
+ owner: true,
79
+ })
80
+ })
81
+
82
+ test("expiresAtBlock maps to the RPC `expiration` field", () => {
83
+ expect(selectionToIncludeData({ expiresAtBlock: true })).toEqual({
84
+ ...EMPTY,
85
+ expiration: true,
86
+ })
87
+ })
88
+
89
+ test("several fields selected together", () => {
90
+ expect(
91
+ selectionToIncludeData({
92
+ owner: true,
93
+ creator: true,
94
+ createdAtBlock: true,
95
+ attributes: true,
96
+ }),
97
+ ).toEqual({
98
+ ...EMPTY,
99
+ owner: true,
100
+ creator: true,
101
+ createdAtBlock: true,
102
+ attributes: true,
103
+ })
104
+ })
105
+
106
+ test("key can be combined with other fields", () => {
107
+ expect(selectionToIncludeData({ key: true, attributes: true })).toEqual({
108
+ ...EMPTY,
109
+ key: true,
110
+ attributes: true,
111
+ })
112
+ })
113
+
114
+ test("a field set to false is not selected", () => {
115
+ expect(selectionToIncludeData({ owner: true, creator: false })).toEqual({
116
+ ...EMPTY,
117
+ owner: true,
118
+ })
119
+ })
120
+ })
@@ -0,0 +1,116 @@
1
+ import type { Entity } from "../types/entity"
2
+ import type { RpcIncludeData } from "../types/rpcSchema"
3
+
4
+ /** The entity fields that can be selected. These match the fields of {@link Entity} one-to-one. */
5
+ type DataKey =
6
+ | "key"
7
+ | "contentType"
8
+ | "owner"
9
+ | "creator"
10
+ | "expiresAtBlock"
11
+ | "createdAtBlock"
12
+ | "lastModifiedAtBlock"
13
+ | "transactionIndexInBlock"
14
+ | "operationIndexInTransaction"
15
+ | "attributes"
16
+ | "payload"
17
+
18
+ /**
19
+ * The parts of an entity that can be selected. Every field is opt-in (including the `key`) and maps
20
+ * directly to a field of the returned {@link Entity} — the selection is flat.
21
+ */
22
+ export type SelectionFields = { [K in DataKey]?: boolean }
23
+
24
+ /**
25
+ * Requires at least one property of `T` to be present.
26
+ * `{}` is not assignable, which is how `select({})` is rejected at compile time.
27
+ */
28
+ type AtLeastOne<T, K extends keyof T = keyof T> = {
29
+ [P in K]: Required<Pick<T, P>> & Partial<Omit<T, P>>
30
+ }[K]
31
+
32
+ /**
33
+ * A non-empty entity selection — the object form accepted by `select()`. At least one field must be
34
+ * selected.
35
+ */
36
+ export type EntitySelection = AtLeastOne<SelectionFields>
37
+
38
+ /** Argument accepted by `select()` — a non-empty {@link EntitySelection}, or `"*"` for everything. */
39
+ export type SelectArg = EntitySelection | "*"
40
+
41
+ /**
42
+ * The result of a selection `S`: an {@link Entity} narrowed to exactly the selected fields (each
43
+ * non-`undefined`). The shape is flat and backwards compatible — e.g. `select({ owner: true })`
44
+ * yields `{ owner: Hex }` accessed as `entity.owner`. Accessing an unselected field is a compile
45
+ * error.
46
+ *
47
+ * The `toText()` / `toJson()` helpers operate on the payload, so they are only present when
48
+ * `payload` was selected — calling them otherwise would always throw.
49
+ */
50
+ export type ProjectedEntity<S> = {
51
+ readonly [K in keyof S as S[K] extends true ? K : never]: K extends keyof Entity
52
+ ? NonNullable<Entity[K]>
53
+ : never
54
+ } & (S extends { payload: true } ? Pick<Entity, "toText" | "toJson"> : unknown)
55
+
56
+ /** A selection of every field, used to type the result of `select()` / `select("*")`. */
57
+ type AllSelected = { [K in DataKey]: true }
58
+
59
+ /** The projected entity type when everything is selected — every field, plus the methods. */
60
+ export type FullEntity = ProjectedEntity<AllSelected>
61
+
62
+ /**
63
+ * Converts a {@link SelectArg} into the {@link RpcIncludeData} understood by the query engine.
64
+ * Every field is opt-in — anything not explicitly selected defaults to `false`, including the key.
65
+ * `undefined` or `"*"` selects everything.
66
+ *
67
+ * @param selection - The selection to convert
68
+ * @returns The fully-resolved include-data object for the RPC query
69
+ * @throws If an empty selection object is provided
70
+ *
71
+ * @example
72
+ * selectionToIncludeData() // everything
73
+ * selectionToIncludeData("*") // everything
74
+ * selectionToIncludeData({ key: true }) // only the key
75
+ * selectionToIncludeData({ owner: true }) // only the owner
76
+ */
77
+ export function selectionToIncludeData(selection?: SelectArg): RpcIncludeData {
78
+ if (selection === undefined || selection === "*") {
79
+ return {
80
+ key: true,
81
+ attributes: true,
82
+ payload: true,
83
+ contentType: true,
84
+ expiration: true,
85
+ owner: true,
86
+ creator: true,
87
+ createdAtBlock: true,
88
+ lastModifiedAtBlock: true,
89
+ transactionIndexInBlock: true,
90
+ operationIndexInTransaction: true,
91
+ }
92
+ }
93
+
94
+ // At least one field must be opted in. This rejects both `{}` and selections whose fields are
95
+ // all explicitly `false` (e.g. `select({ key: false })`), which the type system permits.
96
+ if (!Object.values(selection).some((selected) => selected === true)) {
97
+ throw new Error(
98
+ 'select() requires at least one field to be selected. Pass select() or select("*") to select everything.',
99
+ )
100
+ }
101
+
102
+ return {
103
+ key: selection.key ?? false,
104
+ attributes: selection.attributes ?? false,
105
+ payload: selection.payload ?? false,
106
+ contentType: selection.contentType ?? false,
107
+ // The selection uses the entity field name `expiresAtBlock`; the RPC calls it `expiration`.
108
+ expiration: selection.expiresAtBlock ?? false,
109
+ owner: selection.owner ?? false,
110
+ creator: selection.creator ?? false,
111
+ createdAtBlock: selection.createdAtBlock ?? false,
112
+ lastModifiedAtBlock: selection.lastModifiedAtBlock ?? false,
113
+ transactionIndexInBlock: selection.transactionIndexInBlock ?? false,
114
+ operationIndexInTransaction: selection.operationIndexInTransaction ?? false,
115
+ }
116
+ }
@@ -1,4 +1,16 @@
1
+ /**
2
+ * An entity attribute (key/value pair).
3
+ *
4
+ * Arkiv supports both string and number values. Numeric values **must be
5
+ * integers** — passing a non-integer number (e.g. `1.5`) throws an
6
+ * {@link InvalidAttributeError} at mutation time. To store a non-integer value:
7
+ * - Scale it to an integer (e.g. `1.5` -> `1500`, dividing by the same factor on
8
+ * read) to keep numeric ordering and range queries working.
9
+ * - Or pass it as a string (e.g. `"1.5"`), which sorts lexicographically and so
10
+ * does not support numeric comparisons.
11
+ */
1
12
  export type Attribute = {
2
13
  key: string
14
+ /** Attribute value. A `number` must be an integer (scale non-integers, e.g. `1.5` -> `1500`, or use a string). Throws {@link InvalidAttributeError} otherwise. */
3
15
  value: string | number
4
16
  }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { InvalidAttributeError, InvalidExpirationError } from "../errors"
3
+ import { opsToTxData } from "./arkivTransactions"
4
+
5
+ const validCreate = {
6
+ payload: new Uint8Array([1, 2, 3]),
7
+ contentType: "text/plain",
8
+ attributes: [{ key: "k", value: "v" as string | number }],
9
+ expiresIn: 1000,
10
+ }
11
+
12
+ describe("opsToTxData expiration validation", () => {
13
+ it("builds tx data for a valid create", () => {
14
+ expect(typeof opsToTxData({ creates: [validCreate] })).toBe("string")
15
+ })
16
+
17
+ it("throws when a create expiresIn is not a multiple of the block time", () => {
18
+ expect(() => opsToTxData({ creates: [{ ...validCreate, expiresIn: 51 }] })).toThrow(
19
+ InvalidExpirationError,
20
+ )
21
+ })
22
+
23
+ it("throws when an update expiresIn is not an integer", () => {
24
+ expect(() =>
25
+ opsToTxData({
26
+ updates: [{ ...validCreate, entityKey: "0x123", expiresIn: 51.5 }],
27
+ }),
28
+ ).toThrow(InvalidExpirationError)
29
+ })
30
+
31
+ it("throws when an extension expiresIn is not a multiple of the block time", () => {
32
+ expect(() => opsToTxData({ extensions: [{ entityKey: "0x123", expiresIn: 999 }] })).toThrow(
33
+ InvalidExpirationError,
34
+ )
35
+ })
36
+ })
37
+
38
+ describe("opsToTxData attribute validation", () => {
39
+ it("throws when a numeric attribute value is not an integer", () => {
40
+ expect(() =>
41
+ opsToTxData({
42
+ creates: [{ ...validCreate, attributes: [{ key: "n", value: 1.5 }] }],
43
+ }),
44
+ ).toThrow(InvalidAttributeError)
45
+ })
46
+
47
+ it("accepts integer numeric attribute values", () => {
48
+ expect(
49
+ typeof opsToTxData({
50
+ creates: [{ ...validCreate, attributes: [{ key: "n", value: 123 }] }],
51
+ }),
52
+ ).toBe("string")
53
+ })
54
+
55
+ it("accepts string attribute values that look like non-integers", () => {
56
+ expect(
57
+ typeof opsToTxData({
58
+ creates: [{ ...validCreate, attributes: [{ key: "n", value: "1.5" }] }],
59
+ }),
60
+ ).toBe("string")
61
+ })
62
+ })
@@ -11,6 +11,7 @@ import { EntityMutationError } from "../errors"
11
11
  import type { TxParams } from "../types"
12
12
  import { compress } from "./compression"
13
13
  import { getLogger } from "./logger"
14
+ import { validateAttribute, validateExpiresIn } from "./validation"
14
15
 
15
16
  const logger = getLogger("utils:arkiv-transactions")
16
17
 
@@ -39,37 +40,45 @@ export function opsToTxData({
39
40
 
40
41
  const payload = [
41
42
  //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
- ]),
43
+ (creates ?? []).map((item) => {
44
+ validateExpiresIn(item.expiresIn)
45
+ item.attributes.forEach(validateAttribute)
46
+ return [
47
+ toHex(item.expiresIn / BLOCK_TIME),
48
+ toHex(item.contentType),
49
+ toHex(item.payload),
50
+ item.attributes
51
+ .filter((attribute) => typeof attribute.value === "string")
52
+ .map(formatAttributes),
53
+ item.attributes
54
+ .filter((attribute) => typeof attribute.value === "number")
55
+ .map(formatAttributes),
56
+ ]
57
+ }),
53
58
  //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
- ]),
59
+ (updates ?? []).map((item) => {
60
+ validateExpiresIn(item.expiresIn)
61
+ item.attributes.forEach(validateAttribute)
62
+ return [
63
+ item.entityKey,
64
+ toHex(item.contentType),
65
+ toHex(item.expiresIn / BLOCK_TIME),
66
+ toHex(item.payload),
67
+ item.attributes
68
+ .filter((attribute) => typeof attribute.value === "string")
69
+ .map(formatAttributes),
70
+ item.attributes
71
+ .filter((attribute) => typeof attribute.value === "number")
72
+ .map(formatAttributes),
73
+ ]
74
+ }),
66
75
  //deletes
67
76
  (deletes ?? []).map((item) => item.entityKey),
68
77
  //extends
69
- (extensions ?? []).map((item) => [
70
- item.entityKey,
71
- toHex(Math.ceil(item.expiresIn / BLOCK_TIME)),
72
- ]),
78
+ (extensions ?? []).map((item) => {
79
+ validateExpiresIn(item.expiresIn)
80
+ return [item.entityKey, toHex(item.expiresIn / BLOCK_TIME)]
81
+ }),
73
82
  //ownershipChanges TODO
74
83
  (ownershipChanges ?? []).map((item) => [item.entityKey, item.newOwner]),
75
84
  ]
@@ -1,3 +1,2 @@
1
- export * from "viem/utils"
2
1
  export * from "./expirationTime"
3
2
  export * from "./payload"
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { BLOCK_TIME } from "../consts"
3
+ import { InvalidAttributeError, InvalidExpirationError } from "../errors"
4
+ import { validateAttribute, validateExpiresIn } from "./validation"
5
+
6
+ describe("validateExpiresIn", () => {
7
+ it("accepts positive integers that are a multiple of the block time", () => {
8
+ expect(() => validateExpiresIn(BLOCK_TIME)).not.toThrow()
9
+ expect(() => validateExpiresIn(1000)).not.toThrow()
10
+ expect(() => validateExpiresIn(BLOCK_TIME * 12345)).not.toThrow()
11
+ })
12
+
13
+ it("throws for non-integer values", () => {
14
+ expect(() => validateExpiresIn(51.5)).toThrow(InvalidExpirationError)
15
+ expect(() => validateExpiresIn(1.5)).toThrow(InvalidExpirationError)
16
+ })
17
+
18
+ it("throws for values that are not a multiple of the block time", () => {
19
+ expect(() => validateExpiresIn(51)).toThrow(InvalidExpirationError)
20
+ expect(() => validateExpiresIn(999)).toThrow(InvalidExpirationError)
21
+ expect(() => validateExpiresIn(1)).toThrow(InvalidExpirationError)
22
+ })
23
+
24
+ it("throws for zero and negative values", () => {
25
+ expect(() => validateExpiresIn(0)).toThrow(InvalidExpirationError)
26
+ expect(() => validateExpiresIn(-1000)).toThrow(InvalidExpirationError)
27
+ })
28
+
29
+ it("throws for NaN and non-finite values", () => {
30
+ expect(() => validateExpiresIn(Number.NaN)).toThrow(InvalidExpirationError)
31
+ expect(() => validateExpiresIn(Number.POSITIVE_INFINITY)).toThrow(InvalidExpirationError)
32
+ })
33
+
34
+ it("includes the offending value in the error message", () => {
35
+ expect(() => validateExpiresIn(51)).toThrow(/51/)
36
+ })
37
+ })
38
+
39
+ describe("validateAttribute", () => {
40
+ it("accepts string values", () => {
41
+ expect(() => validateAttribute({ key: "k", value: "1.5" })).not.toThrow()
42
+ expect(() => validateAttribute({ key: "k", value: "anything" })).not.toThrow()
43
+ })
44
+
45
+ it("accepts integer numeric values", () => {
46
+ expect(() => validateAttribute({ key: "k", value: 0 })).not.toThrow()
47
+ expect(() => validateAttribute({ key: "k", value: 123 })).not.toThrow()
48
+ expect(() => validateAttribute({ key: "k", value: -5 })).not.toThrow()
49
+ })
50
+
51
+ it("throws for non-integer numeric values", () => {
52
+ expect(() => validateAttribute({ key: "k", value: 1.5 })).toThrow(InvalidAttributeError)
53
+ expect(() => validateAttribute({ key: "k", value: Number.NaN })).toThrow(InvalidAttributeError)
54
+ })
55
+
56
+ it("includes the attribute key and value in the error message", () => {
57
+ expect(() => validateAttribute({ key: "myKey", value: 1.5 })).toThrow(/myKey/)
58
+ expect(() => validateAttribute({ key: "myKey", value: 1.5 })).toThrow(/1\.5/)
59
+ })
60
+ })