@arkiv-network/sdk 0.7.0-dev.1 → 0.8.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -16
- package/dist/attr/index.cjs +34 -0
- package/dist/attr/index.d.cts +2 -0
- package/dist/attr/index.d.ts +2 -0
- package/dist/attr/index.js +3 -0
- package/dist/attr-BDZ-APg5.js +809 -0
- package/dist/attr-BDZ-APg5.js.map +1 -0
- package/dist/attr-BvxHN99W.cjs +1029 -0
- package/dist/chains/index.cjs +2 -3
- package/dist/chains/index.d.cts +22 -60
- package/dist/chains/index.d.cts.map +1 -1
- package/dist/chains/index.d.ts +22 -60
- package/dist/chains/index.d.ts.map +1 -1
- package/dist/chains/index.js +2 -2
- package/dist/chains-BJrZs6sG.js +57 -0
- package/dist/chains-BJrZs6sG.js.map +1 -0
- package/dist/chains-BYRTTipa.cjs +73 -0
- package/dist/changeOwnership-B7bEgla4.d.cts +509 -0
- package/dist/changeOwnership-B7bEgla4.d.cts.map +1 -0
- package/dist/changeOwnership-DwgtdSFc.d.ts +509 -0
- package/dist/changeOwnership-DwgtdSFc.d.ts.map +1 -0
- package/dist/entity/index.cjs +19 -0
- package/dist/entity/index.d.cts +4 -0
- package/dist/entity/index.d.ts +4 -0
- package/dist/entity/index.js +6 -0
- package/dist/entity-B0t6sN7F.cjs +71 -0
- package/dist/entity-BIp_df9u.js +67 -0
- package/dist/entity-BIp_df9u.js.map +1 -0
- package/dist/entity-CFhbAv0R.js +70 -0
- package/dist/entity-CFhbAv0R.js.map +1 -0
- package/dist/entity-wfBnZd91.cjs +98 -0
- package/dist/errors-D-eJkGVc.cjs +56 -0
- package/dist/errors-DGB8AgT2.js +33 -0
- package/dist/errors-DGB8AgT2.js.map +1 -0
- package/dist/expiry-BUYIXTGb.cjs +135 -0
- package/dist/expiry-BxOlvVWC.d.ts +79 -0
- package/dist/expiry-BxOlvVWC.d.ts.map +1 -0
- package/dist/expiry-DGRzrSan.js +95 -0
- package/dist/expiry-DGRzrSan.js.map +1 -0
- package/dist/expiry-DRV29F1r.d.cts +79 -0
- package/dist/expiry-DRV29F1r.d.cts.map +1 -0
- package/dist/flags-0Vl5rrHq.d.cts +41 -0
- package/dist/flags-0Vl5rrHq.d.cts.map +1 -0
- package/dist/flags-Ct8k0gpB.cjs +43 -0
- package/dist/flags-ZtlvOIIk.js +33 -0
- package/dist/flags-ZtlvOIIk.js.map +1 -0
- package/dist/flags-wmcMoAnN.d.ts +41 -0
- package/dist/flags-wmcMoAnN.d.ts.map +1 -0
- package/dist/index-Cgw1u5aY.d.cts +278 -0
- package/dist/index-Cgw1u5aY.d.cts.map +1 -0
- package/dist/index-ClgIuxJ8.d.ts +159 -0
- package/dist/index-ClgIuxJ8.d.ts.map +1 -0
- package/dist/index-D0CmtiWT.d.cts +159 -0
- package/dist/index-D0CmtiWT.d.cts.map +1 -0
- package/dist/index-D8LZt-3J.d.cts +417 -0
- package/dist/index-D8LZt-3J.d.cts.map +1 -0
- package/dist/index-DVYFaXNV.d.ts +417 -0
- package/dist/index-DVYFaXNV.d.ts.map +1 -0
- package/dist/index-VCqgn29D.d.ts +278 -0
- package/dist/index-VCqgn29D.d.ts.map +1 -0
- package/dist/index.cjs +676 -375
- package/dist/index.d.cts +247 -208
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +247 -208
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +626 -368
- package/dist/index.js.map +1 -1
- package/dist/payload-Bnw1hrvb.cjs +83 -0
- package/dist/payload-CIxSn_DW.js +67 -0
- package/dist/payload-CIxSn_DW.js.map +1 -0
- package/dist/payload-Dgx3PQeK.d.ts +94 -0
- package/dist/payload-Dgx3PQeK.d.ts.map +1 -0
- package/dist/payload-P0nWyjLg.d.cts +94 -0
- package/dist/payload-P0nWyjLg.d.cts.map +1 -0
- package/dist/query/index.cjs +24 -150
- package/dist/query/index.d.cts +5 -3
- package/dist/query/index.d.ts +5 -3
- package/dist/query/index.js +6 -138
- package/dist/queryBuilder-BXDvX1K_.cjs +1123 -0
- package/dist/queryBuilder-CbLL4pG5.js +963 -0
- package/dist/queryBuilder-CbLL4pG5.js.map +1 -0
- package/dist/selection-BvK6yX0g.d.cts +433 -0
- package/dist/selection-BvK6yX0g.d.cts.map +1 -0
- package/dist/selection-fpmI3ADQ.d.ts +433 -0
- package/dist/selection-fpmI3ADQ.d.ts.map +1 -0
- package/dist/types/index.cjs +3 -3
- package/dist/types/index.d.cts +7 -2
- package/dist/types/index.d.ts +7 -2
- package/dist/types/index.js +3 -3
- package/dist/utils/index.cjs +4 -17
- package/dist/utils/index.d.cts +3 -17
- package/dist/utils/index.d.ts +3 -17
- package/dist/utils/index.js +4 -18
- package/package.json +26 -5
- package/src/actions/public/getEntity.ts +27 -43
- package/src/actions/public/getEntityNonce.ts +30 -0
- package/src/actions/public/predictEntityKeys.test.ts +143 -0
- package/src/actions/public/predictEntityKeys.ts +138 -0
- package/src/actions/public/query.ts +33 -69
- package/src/actions/public/watchEntityEvents.test.ts +414 -0
- package/src/actions/public/watchEntityEvents.ts +225 -0
- package/src/actions/wallet/changeOwnership.ts +2 -2
- package/src/actions/wallet/createEntity.ts +99 -24
- package/src/actions/wallet/extendEntity.ts +37 -13
- package/src/actions/wallet/mutateEntities.test.ts +76 -0
- package/src/actions/wallet/mutateEntities.ts +19 -21
- package/src/actions/wallet/patchEntity.ts +90 -0
- package/src/attr/attributes.test.ts +120 -0
- package/src/attr/attributes.ts +190 -0
- package/src/attr/codec.test.ts +162 -0
- package/src/attr/codec.ts +220 -0
- package/src/attr/decimal.ts +86 -0
- package/src/attr/errors.ts +145 -0
- package/src/attr/index.ts +50 -0
- package/src/attr/names.ts +106 -0
- package/src/attr/types.ts +150 -0
- package/src/attr/values.test.ts +271 -0
- package/src/attr/values.ts +502 -0
- package/src/chains/cheesecake.ts +22 -0
- package/src/chains/index.ts +1 -2
- package/src/clients/createPublicClient.test.ts +3 -5
- package/src/clients/createPublicClient.ts +5 -4
- package/src/clients/createWalletClient.test.ts +2 -2
- package/src/clients/createWalletClient.ts +13 -7
- package/src/clients/decorators/arkivPublic.ts +160 -139
- package/src/clients/decorators/arkivWallet.ts +86 -94
- package/src/entity/create.test.ts +184 -0
- package/src/entity/errors.ts +38 -0
- package/src/entity/events.ts +16 -0
- package/src/entity/expiry.test.ts +195 -0
- package/src/entity/expiry.ts +181 -0
- package/src/entity/flags.ts +65 -0
- package/src/entity/index.ts +14 -0
- package/src/entity/key.ts +68 -0
- package/src/entity/operations.ts +134 -0
- package/src/entity/params.ts +15 -0
- package/src/errors.ts +26 -49
- package/src/index.ts +6 -0
- package/src/query/engine.ts +59 -139
- package/src/query/errors.ts +151 -0
- package/src/query/expression.test.ts +286 -0
- package/src/query/expression.ts +493 -0
- package/src/query/index.ts +40 -4
- package/src/query/injection.test.ts +151 -0
- package/src/query/queryBuilder.test.ts +322 -637
- package/src/query/queryBuilder.ts +126 -359
- package/src/query/queryResult.ts +54 -51
- package/src/query/selection.test.ts +48 -98
- package/src/query/selection.ts +77 -65
- package/src/types/entity.test.ts +44 -155
- package/src/types/entity.ts +60 -52
- package/src/types/events.ts +59 -16
- package/src/types/index.ts +20 -17
- package/src/types/rpcSchema.ts +69 -37
- package/src/utils/arkivTransactions.test.ts +600 -155
- package/src/utils/arkivTransactions.ts +181 -190
- package/src/utils/chains.test.ts +21 -24
- package/src/utils/compression.ts +1 -1
- package/src/utils/entities.test.ts +62 -0
- package/src/utils/entities.ts +109 -37
- package/src/utils/expirationTime.ts +114 -10
- package/src/utils/logger.ts +2 -2
- package/src/utils/revert.test.ts +99 -0
- package/src/utils/revert.ts +145 -0
- package/dist/chains-BifaY04V.cjs +0 -108
- package/dist/chains-CrtkVbBh.js +0 -86
- package/dist/chains-CrtkVbBh.js.map +0 -1
- package/dist/consts-Bz0LZQZY.js +0 -7
- package/dist/consts-Bz0LZQZY.js.map +0 -1
- package/dist/consts-CxT1-RXU.cjs +0 -18
- package/dist/entity-BhSXaB23.cjs +0 -81
- package/dist/entity-CN41IOrq.d.cts +0 -401
- package/dist/entity-CN41IOrq.d.cts.map +0 -1
- package/dist/entity-DGfVyB-a.js +0 -71
- package/dist/entity-DGfVyB-a.js.map +0 -1
- package/dist/entity-NnEVlePN.d.ts +0 -401
- package/dist/entity-NnEVlePN.d.ts.map +0 -1
- package/dist/index-ClkpS6s_.d.ts +0 -481
- package/dist/index-ClkpS6s_.d.ts.map +0 -1
- package/dist/index-w4qop0Pe.d.cts +0 -481
- package/dist/index-w4qop0Pe.d.cts.map +0 -1
- package/dist/payload-BpNUPOnY.js +0 -13
- package/dist/payload-BpNUPOnY.js.map +0 -1
- package/dist/payload-C2KHOUoj.d.ts +0 -6
- package/dist/payload-C2KHOUoj.d.ts.map +0 -1
- package/dist/payload-CJN8fTvX.d.cts +0 -6
- package/dist/payload-CJN8fTvX.d.cts.map +0 -1
- package/dist/payload-D8YLKcJj.cjs +0 -23
- package/dist/query/index.js.map +0 -1
- package/dist/queryBuilder-BE_XhLLa.js +0 -735
- package/dist/queryBuilder-BE_XhLLa.js.map +0 -1
- package/dist/queryBuilder-J37buGCi.cjs +0 -865
- package/dist/utils/index.d.cts.map +0 -1
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js.map +0 -1
- package/src/actions/public/buildQuery.ts +0 -10
- package/src/actions/public/subscribeEntityEvents.ts +0 -93
- package/src/actions/wallet/updateEntity.ts +0 -59
- package/src/chains/braga.ts +0 -26
- package/src/chains/kaolin.ts +0 -29
- package/src/query/engine.test.ts +0 -571
- package/src/query/predicate.test.ts +0 -461
- package/src/query/predicate.ts +0 -144
- package/src/query/queryResult.test.ts +0 -270
- package/src/query/selectQueryBuilder.test.ts +0 -213
- package/src/types/attributes.ts +0 -28
- package/src/utils/validation.test.ts +0 -106
- package/src/utils/validation.ts +0 -80
- /package/dist/{types-Ci4BRClw.js → types-Bx1EKe0v.js} +0 -0
- /package/dist/{types-a8gm_IaQ.cjs → types-CKPddlfS.cjs} +0 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { type AbiAttribute, encodeAbiAttribute, encodeTombstone } from "./codec"
|
|
2
|
+
import { ConflictingMutationError, InvalidValueError, TooManyAttributesError } from "./errors"
|
|
3
|
+
import { validateAttributeName } from "./names"
|
|
4
|
+
import type { AnyArkivValue, ArkivValue, TypeTag } from "./types"
|
|
5
|
+
import { makeValue } from "./types"
|
|
6
|
+
import { str, toValue, type ValueInput } from "./values"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The most attribute cells one operation may carry.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_ATTRIBUTES = 32
|
|
12
|
+
|
|
13
|
+
export const MAX_PAYLOAD_BYTES = 128 * 1024
|
|
14
|
+
|
|
15
|
+
/** The system attribute holding an entity's opaque payload. */
|
|
16
|
+
const PAYLOAD_CELL = "$payload"
|
|
17
|
+
/** The system attribute holding an entity's content type. */
|
|
18
|
+
const CONTENT_TYPE_CELL = "$contentType"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The attributes written on an entity, as a plain object keyed by attribute name.
|
|
22
|
+
*
|
|
23
|
+
* Values may be tagged ({@link ValueInput}) or bare where the type is unambiguous — see
|
|
24
|
+
* {@link toValue} for the bare-form defaults.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const attributes = {
|
|
28
|
+
* level: i32(10),
|
|
29
|
+
* balance: u256(1_000_000n),
|
|
30
|
+
* score: dec("3.5"),
|
|
31
|
+
* parent: key(parentKey),
|
|
32
|
+
* name: "Bob", // bare string -> str
|
|
33
|
+
* flagged: true, // bare boolean -> bool
|
|
34
|
+
* } satisfies AttributeInputs
|
|
35
|
+
*/
|
|
36
|
+
export type AttributeInputs = Readonly<Record<string, ValueInput>>
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The attributes read back from an entity: the same shape, with every value typed.
|
|
40
|
+
*
|
|
41
|
+
* A value read from here drops straight back into an {@link AttributeInputs} — the vocabulary is the
|
|
42
|
+
* same in both directions.
|
|
43
|
+
*/
|
|
44
|
+
export type Attributes = Readonly<Record<string, AnyArkivValue>>
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The names and types of an entity's attributes, with no values — what the `attributeSchema`
|
|
48
|
+
* projection returns.
|
|
49
|
+
*
|
|
50
|
+
* Useful for asking what shape the data has without paying to transfer it, and for telling apart
|
|
51
|
+
* two entities that use the same name with different types.
|
|
52
|
+
*/
|
|
53
|
+
export type AttributeSchema = Readonly<Record<string, TypeTag>>
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The typed form of an attribute map: names validated, bare values resolved to their default type.
|
|
57
|
+
*
|
|
58
|
+
* @throws {InvalidAttributeNameError} If a name violates the attribute-name grammar.
|
|
59
|
+
* @throws {MissingValueError} If a value is `undefined` or `null` — omit the name instead.
|
|
60
|
+
* @throws {InvalidValueError} If a value does not fit the type it names or defaults to.
|
|
61
|
+
* @throws {TooManyAttributesError} If there are more than {@link MAX_ATTRIBUTES} attributes.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* resolveAttributes({ level: 10, name: "Bob" })
|
|
65
|
+
* // { level: { type: "i32", value: 10 }, name: { type: "str", value: "Bob" } }
|
|
66
|
+
*/
|
|
67
|
+
export function resolveAttributes(attributes: AttributeInputs): Record<string, ArkivValue> {
|
|
68
|
+
const entries = Object.entries(attributes)
|
|
69
|
+
if (entries.length > MAX_ATTRIBUTES) {
|
|
70
|
+
throw new TooManyAttributesError(entries.length, MAX_ATTRIBUTES)
|
|
71
|
+
}
|
|
72
|
+
const resolved: Record<string, ArkivValue> = {}
|
|
73
|
+
for (const [name, input] of entries) {
|
|
74
|
+
validateAttributeName(name)
|
|
75
|
+
resolved[name] = toValue(input, name)
|
|
76
|
+
}
|
|
77
|
+
return resolved
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The payload and content type, which travel to the engine as system attributes.
|
|
82
|
+
*
|
|
83
|
+
* They are separate fields on the SDK's surface because that is what they are to an application —
|
|
84
|
+
* an entity's contents and its type — but on the wire an operation carries no dedicated field for
|
|
85
|
+
* either: `$payload` is the one `bytes`-typed cell, and `$contentType` is a `str`.
|
|
86
|
+
*/
|
|
87
|
+
export type SystemCells = {
|
|
88
|
+
payload?: Uint8Array | undefined
|
|
89
|
+
contentType?: string | undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Encodes an attribute map, plus the payload and content-type system cells, into the ABI attribute
|
|
94
|
+
* array an operation carries.
|
|
95
|
+
*
|
|
96
|
+
* @throws {InvalidAttributeNameError} If a name violates the attribute-name grammar.
|
|
97
|
+
* @throws {MissingValueError} If a value is `undefined` or `null`.
|
|
98
|
+
* @throws {InvalidValueError} If a value does not fit its type.
|
|
99
|
+
* @throws {TooManyAttributesError} If there are more than {@link MAX_ATTRIBUTES} attributes.
|
|
100
|
+
*/
|
|
101
|
+
export function encodeAttributes(
|
|
102
|
+
attributes: AttributeInputs = {},
|
|
103
|
+
system: SystemCells = {},
|
|
104
|
+
): AbiAttribute[] {
|
|
105
|
+
const cells: [string, AnyArkivValue][] = Object.entries(resolveAttributes(attributes))
|
|
106
|
+
|
|
107
|
+
// Written whenever the field is given, empty or not: an empty payload is a payload, and dropping
|
|
108
|
+
// it would mean the entity does not hold what the caller passed. Absence is for the operations
|
|
109
|
+
// that genuinely leave a cell alone, not for a value that happens to be empty.
|
|
110
|
+
if (system.payload !== undefined) {
|
|
111
|
+
if (system.payload.length > MAX_PAYLOAD_BYTES) {
|
|
112
|
+
throw new InvalidValueError(
|
|
113
|
+
"bytes",
|
|
114
|
+
`<${system.payload.length} bytes>`,
|
|
115
|
+
`${system.payload.length} bytes exceeds the ${MAX_PAYLOAD_BYTES}-byte payload limit`,
|
|
116
|
+
"Store the bulk elsewhere and keep a reference to it on the entity.",
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
cells.push([PAYLOAD_CELL, makeValue("bytes", toHexBytes(system.payload))])
|
|
120
|
+
}
|
|
121
|
+
if (system.contentType !== undefined) {
|
|
122
|
+
cells.push([CONTENT_TYPE_CELL, str(system.contentType)])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (cells.length > MAX_ATTRIBUTES) {
|
|
126
|
+
throw new TooManyAttributesError(cells.length, MAX_ATTRIBUTES)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return cells.map(([name, value]) => encodeAbiAttribute(name, value)).sort(byName)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type MutationInputs = SystemCells & {
|
|
133
|
+
/** Attributes to write. Existing ones are overwritten; new ones are added. */
|
|
134
|
+
set?: AttributeInputs | undefined
|
|
135
|
+
/** Attribute names to remove. Each becomes a tombstone on the wire. */
|
|
136
|
+
unset?: readonly string[] | undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Encodes a patch's mutations into the ABI attribute array the operation carries.
|
|
141
|
+
*
|
|
142
|
+
* @throws {ConflictingMutationError} If a name appears in both `set` and `unset`.
|
|
143
|
+
* @throws {InvalidAttributeNameError} If a name violates the attribute-name grammar.
|
|
144
|
+
* @throws {InvalidValueError} If a value does not fit its type.
|
|
145
|
+
* @throws {TooManyAttributesError} If `set` names more than {@link MAX_ATTRIBUTES} attributes.
|
|
146
|
+
*/
|
|
147
|
+
export function encodeMutations({
|
|
148
|
+
set,
|
|
149
|
+
unset,
|
|
150
|
+
payload,
|
|
151
|
+
contentType,
|
|
152
|
+
}: MutationInputs): AbiAttribute[] {
|
|
153
|
+
const written = encodeAttributes(set, { payload, contentType })
|
|
154
|
+
const tombstones = resolveUnset(unset, set).map(encodeTombstone)
|
|
155
|
+
const mutations = [...written, ...tombstones]
|
|
156
|
+
if (mutations.length > MAX_ATTRIBUTES) {
|
|
157
|
+
throw new TooManyAttributesError(mutations.length, MAX_ATTRIBUTES)
|
|
158
|
+
}
|
|
159
|
+
return mutations.sort(byName)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resolveUnset(unset: readonly string[] = [], set: AttributeInputs = {}): string[] {
|
|
163
|
+
if (!Array.isArray(unset)) {
|
|
164
|
+
throw new TypeError(`"unset" must be an array of attribute names`)
|
|
165
|
+
}
|
|
166
|
+
const names = new Set(unset)
|
|
167
|
+
for (const name of names) {
|
|
168
|
+
validateAttributeName(name)
|
|
169
|
+
if (Object.hasOwn(set, name)) {
|
|
170
|
+
throw new ConflictingMutationError(name)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return [...names]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The engine requires attributes **strictly ascending by their `bytes32` name**: that ordering is
|
|
178
|
+
* what the canonical serialization hashes into the state root, and it doubles as the uniqueness
|
|
179
|
+
* check.
|
|
180
|
+
*/
|
|
181
|
+
function byName(a: AbiAttribute, b: AbiAttribute): number {
|
|
182
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Raw bytes as lowercase `0x` hex, without viem's hex-string ambiguity. */
|
|
186
|
+
function toHexBytes(bytes: Uint8Array): `0x${string}` {
|
|
187
|
+
let out = "0x"
|
|
188
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, "0")
|
|
189
|
+
return out as `0x${string}`
|
|
190
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import type { Hex } from "viem"
|
|
3
|
+
import {
|
|
4
|
+
decodeRpcValue,
|
|
5
|
+
decodeValueBytes,
|
|
6
|
+
encodeAbiAttribute,
|
|
7
|
+
encodeTombstone,
|
|
8
|
+
encodeValueBytes,
|
|
9
|
+
} from "./codec"
|
|
10
|
+
import { InvalidValueError, UnknownAttributeTypeError } from "./errors"
|
|
11
|
+
import type { ArkivValue } from "./types"
|
|
12
|
+
import { addr, bool, bytes32, dec, i32, key, str, u64, u256 } from "./values"
|
|
13
|
+
|
|
14
|
+
const ZERO = `0x${"00".repeat(32)}` as Hex
|
|
15
|
+
const KEY = `0x${"ab".repeat(32)}` as Hex
|
|
16
|
+
const VITALIK = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
|
|
17
|
+
|
|
18
|
+
/** One value of every user-settable type, including the awkward edges of each. */
|
|
19
|
+
const SAMPLES: ArkivValue[] = [
|
|
20
|
+
bool(true),
|
|
21
|
+
bool(false),
|
|
22
|
+
i32(0),
|
|
23
|
+
i32(-42),
|
|
24
|
+
i32(2147483647),
|
|
25
|
+
i32(-2147483648),
|
|
26
|
+
u64(0n),
|
|
27
|
+
u64(2n ** 64n - 1n),
|
|
28
|
+
u256(0n),
|
|
29
|
+
u256(2n ** 256n - 1n),
|
|
30
|
+
dec("0"),
|
|
31
|
+
dec("3.5"),
|
|
32
|
+
dec("-3.5"),
|
|
33
|
+
dec("-0.000000000000000001"),
|
|
34
|
+
bytes32(KEY),
|
|
35
|
+
key(KEY),
|
|
36
|
+
addr(VITALIK),
|
|
37
|
+
str(""),
|
|
38
|
+
str("hello"),
|
|
39
|
+
str("é".repeat(64)),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
describe("ABI value encoding", () => {
|
|
43
|
+
it("round-trips every type", () => {
|
|
44
|
+
for (const value of SAMPLES) {
|
|
45
|
+
const back = decodeValueBytes(value.type, encodeValueBytes(value))
|
|
46
|
+
expect(back.type).toBe(value.type)
|
|
47
|
+
expect(back.value).toEqual(value.value)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("gives every word type exactly 32 bytes", () => {
|
|
52
|
+
for (const value of SAMPLES) {
|
|
53
|
+
if (value.type === "str") continue
|
|
54
|
+
expect(encodeValueBytes(value)).toHaveLength(2 + 64)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("sign-extends a negative i32 across the whole word, as Solidity does", () => {
|
|
59
|
+
expect(encodeValueBytes(i32(-1))).toBe(`0x${"ff".repeat(32)}`)
|
|
60
|
+
expect(encodeValueBytes(i32(-42))).toBe(`0x${"ff".repeat(31)}d6`)
|
|
61
|
+
expect(encodeValueBytes(i32(42))).toBe(`0x${"00".repeat(31)}2a`)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("stores a dec as its two's-complement scaled int256", () => {
|
|
65
|
+
const scaled = (value: string) => BigInt.asIntN(256, BigInt(encodeValueBytes(dec(value))))
|
|
66
|
+
expect(scaled("1.5")).toBe(1_500_000_000_000_000_000n)
|
|
67
|
+
expect(scaled("-1.5")).toBe(-1_500_000_000_000_000_000n)
|
|
68
|
+
expect(scaled("0")).toBe(0n)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("right-aligns an address and a bool", () => {
|
|
72
|
+
expect(encodeValueBytes(addr(VITALIK))).toBe(
|
|
73
|
+
`0x${"00".repeat(12)}${VITALIK.slice(2).toLowerCase()}`,
|
|
74
|
+
)
|
|
75
|
+
expect(encodeValueBytes(bool(true))).toBe(`0x${"00".repeat(31)}01`)
|
|
76
|
+
expect(encodeValueBytes(bool(false))).toBe(ZERO)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("gives a str its raw UTF-8 bytes, with no padding", () => {
|
|
80
|
+
expect(encodeValueBytes(str("hi"))).toBe("0x6869")
|
|
81
|
+
expect(encodeValueBytes(str(""))).toBe("0x")
|
|
82
|
+
// Two bytes per "é", so no relationship to a 32-byte word.
|
|
83
|
+
expect(encodeValueBytes(str("é"))).toBe("0xc3a9")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("rejects a word a well-formed encoder would not have produced", () => {
|
|
87
|
+
expect(() => decodeValueBytes("bool", `0x${"00".repeat(31)}02`)).toThrow(/exactly 0 or 1/)
|
|
88
|
+
expect(() => decodeValueBytes("u256", "0x2a")).toThrow(/must be 32 bytes, got 1/)
|
|
89
|
+
// An i32 whose upper bytes contradict its sign bit.
|
|
90
|
+
expect(() => decodeValueBytes("i32", `0xff${"00".repeat(31)}`)).toThrow(/sign-extended/)
|
|
91
|
+
// An address with junk above its 20 bytes.
|
|
92
|
+
expect(() => decodeValueBytes("addr", `0x${"ab".repeat(32)}`)).toThrow(/zero-padded/)
|
|
93
|
+
// A u64 with junk above its 8 bytes — truncating it would invent a plausible block height.
|
|
94
|
+
expect(() => decodeValueBytes("u64", `0x${"ab".repeat(32)}`)).toThrow(/zero-padded/)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe("encodeAbiAttribute", () => {
|
|
99
|
+
it("packs the name left-aligned into its bytes32 cell", () => {
|
|
100
|
+
const attribute = encodeAbiAttribute("level", i32(10))
|
|
101
|
+
// "level" is 6c6576656c, null-padded to 32 bytes.
|
|
102
|
+
expect(attribute.name).toBe(`0x6c6576656c${"00".repeat(27)}`)
|
|
103
|
+
expect(attribute.typeId).toBe(2)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("encodes a tombstone as a zero-length value under typeId 0", () => {
|
|
107
|
+
expect(encodeTombstone("flagged")).toEqual({
|
|
108
|
+
name: `0x666c6167676564${"00".repeat(25)}`,
|
|
109
|
+
typeId: 0,
|
|
110
|
+
value: "0x",
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe("decodeRpcValue", () => {
|
|
116
|
+
it("reads the JSON encoding each type declares", () => {
|
|
117
|
+
expect(decodeRpcValue("bool", true).value).toBe(true)
|
|
118
|
+
expect(decodeRpcValue("i32", -42).value).toBe(-42)
|
|
119
|
+
expect(decodeRpcValue("u256", "0xf4240").value).toBe(1_000_000n)
|
|
120
|
+
expect(decodeRpcValue("dec", "3.5").value).toBe("3.5")
|
|
121
|
+
expect(decodeRpcValue("dec", "-0.25").value).toBe("-0.25")
|
|
122
|
+
expect(decodeRpcValue("bytes32", KEY).value).toBe(KEY)
|
|
123
|
+
expect(decodeRpcValue("str", "hello").value).toBe("hello")
|
|
124
|
+
expect(decodeRpcValue("addr", VITALIK.toLowerCase()).value).toBe(VITALIK)
|
|
125
|
+
expect(decodeRpcValue("key", KEY).value).toBe(KEY)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("takes an integer however it is rendered", () => {
|
|
129
|
+
// QUANTITY is the spec's encoding; decimal strings and numbers are tolerated.
|
|
130
|
+
expect(decodeRpcValue("u256", "0x2a").value).toBe(42n)
|
|
131
|
+
expect(decodeRpcValue("u256", "42").value).toBe(42n)
|
|
132
|
+
expect(decodeRpcValue("u256", 42).value).toBe(42n)
|
|
133
|
+
expect(decodeRpcValue("i32", "-42").value).toBe(-42)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("decodes the system-only bytes type, which only ever arrives as a payload", () => {
|
|
137
|
+
expect(decodeRpcValue("bytes", "0xDEADBEEF").value).toBe("0xdeadbeef")
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("names the type it does not recognise, and says to upgrade", () => {
|
|
141
|
+
expect(() => decodeRpcValue("int", "x")).toThrow(UnknownAttributeTypeError)
|
|
142
|
+
expect(() => decodeRpcValue("int", "x")).toThrow(/upgrade @arkiv-network\/sdk/)
|
|
143
|
+
// The pre-spec wire form: a typeId where the JSON-RPC surface names a tag.
|
|
144
|
+
expect(() => decodeRpcValue(3 as unknown as string, "0x2a")).toThrow(UnknownAttributeTypeError)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("does not mistake an inherited Object key for a type tag", () => {
|
|
148
|
+
// `type in TYPE_IDS` walks the prototype chain, so these passed the check and then fell off
|
|
149
|
+
// the end of the exhaustive switch, returning `undefined` — the caller got a TypeError from
|
|
150
|
+
// `entity.attributes.foo.value` instead of "upgrade the SDK".
|
|
151
|
+
for (const inherited of ["constructor", "toString", "valueOf", "hasOwnProperty"]) {
|
|
152
|
+
expect(() => decodeRpcValue(inherited, 1)).toThrow(UnknownAttributeTypeError)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("rejects a value whose JSON shape contradicts its declared type", () => {
|
|
157
|
+
expect(() => decodeRpcValue("u256", "not a number")).toThrow(InvalidValueError)
|
|
158
|
+
expect(() => decodeRpcValue("str", 42)).toThrow(/expected a string/)
|
|
159
|
+
expect(() => decodeRpcValue("bool", "yes")).toThrow(/not a boolean/)
|
|
160
|
+
expect(() => decodeRpcValue("i32", 1.5)).toThrow(/not an integer/)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { bytesToString, type Hex, hexToBytes, pad, size, stringToBytes, toHex } from "viem"
|
|
2
|
+
import { unitsToDecimal } from "./decimal"
|
|
3
|
+
import { InvalidValueError, UnknownAttributeTypeError } from "./errors"
|
|
4
|
+
import { type AnyArkivValue, makeValue, type StrValue, TYPE_IDS, type TypeTag } from "./types"
|
|
5
|
+
import { addr, bool, bytes32, dec, decUnits, i32, key, u64, u256 } from "./values"
|
|
6
|
+
|
|
7
|
+
function strFromChain(value: string): StrValue {
|
|
8
|
+
return makeValue("str", value)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The `typeId` marking a tombstone — an attribute being unset. It carries a zero-length value and
|
|
13
|
+
* is legal only in a patch's mutation list, never in a create.
|
|
14
|
+
*/
|
|
15
|
+
export const TOMBSTONE_TYPE_ID = 0
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An attribute in its ABI form: `(Ident32 name, uint8 typeId, bytes value)`.
|
|
19
|
+
*
|
|
20
|
+
* The `value` encoding is selected by `typeId`: an exact 32-byte word for the word types, the raw
|
|
21
|
+
* bytes for `str` and `bytes`, and zero-length for a tombstone.
|
|
22
|
+
*/
|
|
23
|
+
export type AbiAttribute = {
|
|
24
|
+
name: Hex
|
|
25
|
+
typeId: number
|
|
26
|
+
value: Hex
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Encodes a typed value into the `bytes` the ABI carries for it.
|
|
31
|
+
*
|
|
32
|
+
* This and {@link decodeValueBytes} are the only place the value/wire mapping is written down.
|
|
33
|
+
*
|
|
34
|
+
* - Word types (`bool`, `i32`, `u64`, `u256`, `dec`, `bytes32`, `addr`, `key`) become exactly 32
|
|
35
|
+
* bytes, in the encoding Solidity would use — right-aligned, and sign-extended for `i32`.
|
|
36
|
+
* - `str` becomes its raw UTF-8 bytes, and `bytes` its raw bytes: variable length, no padding.
|
|
37
|
+
*/
|
|
38
|
+
export function encodeValueBytes(value: AnyArkivValue): Hex {
|
|
39
|
+
switch (value.type) {
|
|
40
|
+
case "bool":
|
|
41
|
+
return pad(value.value ? "0x01" : "0x00", { size: 32 })
|
|
42
|
+
case "i32":
|
|
43
|
+
// int32 is sign-extended across the whole word, as Solidity encodes it.
|
|
44
|
+
return toHex(BigInt.asUintN(256, BigInt(value.value)), { size: 32 })
|
|
45
|
+
case "u64":
|
|
46
|
+
case "u256":
|
|
47
|
+
return toHex(value.value, { size: 32 })
|
|
48
|
+
case "dec":
|
|
49
|
+
return toHex(BigInt.asUintN(256, decUnits(value)), { size: 32 })
|
|
50
|
+
case "bytes32":
|
|
51
|
+
case "key":
|
|
52
|
+
return value.value
|
|
53
|
+
case "addr":
|
|
54
|
+
return pad(value.value.toLowerCase() as Hex, { size: 32 })
|
|
55
|
+
case "str":
|
|
56
|
+
return toHex(stringToBytes(value.value))
|
|
57
|
+
case "bytes":
|
|
58
|
+
return value.value
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decodes the ABI `bytes` of an attribute of type `tag`.
|
|
64
|
+
*
|
|
65
|
+
* Rejects anything a well-formed encoder would not have produced — a word type whose value is not
|
|
66
|
+
* 32 bytes, an `i32` that is not sign-extended, a `bool` that is neither 0 nor 1 — rather than
|
|
67
|
+
* silently truncating it.
|
|
68
|
+
*/
|
|
69
|
+
export function decodeValueBytes(tag: TypeTag, value: Hex): AnyArkivValue {
|
|
70
|
+
if (tag === "str") return strFromChain(bytesToString(hexToBytes(value)))
|
|
71
|
+
if (tag === "bytes") return makeValue("bytes", value.toLowerCase() as Hex)
|
|
72
|
+
|
|
73
|
+
const width = size(value)
|
|
74
|
+
if (width !== 32) {
|
|
75
|
+
throw new InvalidValueError(tag, value, `a ${tag} value must be 32 bytes, got ${width}`)
|
|
76
|
+
}
|
|
77
|
+
const raw = BigInt(value)
|
|
78
|
+
|
|
79
|
+
switch (tag) {
|
|
80
|
+
case "bool":
|
|
81
|
+
if (raw !== 0n && raw !== 1n) {
|
|
82
|
+
throw new InvalidValueError("bool", value, "a bool word must hold exactly 0 or 1")
|
|
83
|
+
}
|
|
84
|
+
return bool(raw === 1n)
|
|
85
|
+
case "i32": {
|
|
86
|
+
const narrowed = BigInt.asIntN(32, raw)
|
|
87
|
+
if (BigInt.asUintN(256, narrowed) !== raw) {
|
|
88
|
+
throw new InvalidValueError("i32", value, "an i32 word must be sign-extended")
|
|
89
|
+
}
|
|
90
|
+
return i32(narrowed)
|
|
91
|
+
}
|
|
92
|
+
case "u64":
|
|
93
|
+
if (raw >> 64n !== 0n) {
|
|
94
|
+
throw new InvalidValueError("u64", value, "a u64 word must be zero-padded to 8 bytes")
|
|
95
|
+
}
|
|
96
|
+
return u64(raw)
|
|
97
|
+
case "u256":
|
|
98
|
+
return u256(raw)
|
|
99
|
+
case "dec":
|
|
100
|
+
return dec(unitsToDecimal(BigInt.asIntN(256, raw)))
|
|
101
|
+
case "bytes32":
|
|
102
|
+
return bytes32(value)
|
|
103
|
+
case "key":
|
|
104
|
+
return key(value)
|
|
105
|
+
case "addr": {
|
|
106
|
+
if (raw >> 160n !== 0n) {
|
|
107
|
+
throw new InvalidValueError(
|
|
108
|
+
"addr",
|
|
109
|
+
value,
|
|
110
|
+
"an address word must be zero-padded to 20 bytes",
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
return addr(`0x${value.slice(-40)}`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Encodes a named, typed attribute into its ABI form.
|
|
120
|
+
*
|
|
121
|
+
* @param name - The attribute name. Assumed already validated, or a system (`$`) name the SDK sets
|
|
122
|
+
* itself.
|
|
123
|
+
*/
|
|
124
|
+
export function encodeAbiAttribute(name: string, value: AnyArkivValue): AbiAttribute {
|
|
125
|
+
return {
|
|
126
|
+
// Ident32 is left-aligned and null-padded, which is what `toHex` does for a string.
|
|
127
|
+
name: toHex(name, { size: 32 }),
|
|
128
|
+
typeId: TYPE_IDS[value.type],
|
|
129
|
+
value: encodeValueBytes(value),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Encodes a tombstone — the mutation that unsets `name`. Legal only in a patch. */
|
|
134
|
+
export function encodeTombstone(name: string): AbiAttribute {
|
|
135
|
+
return { name: toHex(name, { size: 32 }), typeId: TOMBSTONE_TYPE_ID, value: "0x" }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Decodes an attribute value as it comes back over JSON-RPC.
|
|
140
|
+
*
|
|
141
|
+
* The JSON encoding follows the value's **declared type**, never its magnitude: `bool` is a JSON
|
|
142
|
+
* boolean, `i32` a JSON number, `u64`/`u256` a `0x` QUANTITY, `dec` a decimal string, `str` a
|
|
143
|
+
* string, and
|
|
144
|
+
* the byte-shaped types `0x` DATA. Decimal strings are also accepted for the integer types, so this
|
|
145
|
+
* keeps working against a node that renders them that way.
|
|
146
|
+
*
|
|
147
|
+
* @param type - The type tag the response declared, e.g. `"u256"`.
|
|
148
|
+
* @param value - The JSON value.
|
|
149
|
+
* @throws {UnknownAttributeTypeError} If the tag names no known type.
|
|
150
|
+
* @throws {InvalidValueError} If the value does not parse as that type.
|
|
151
|
+
*/
|
|
152
|
+
export function decodeRpcValue(type: string, value: unknown): AnyArkivValue {
|
|
153
|
+
const tag = asTypeTag(type)
|
|
154
|
+
switch (tag) {
|
|
155
|
+
case "bool":
|
|
156
|
+
return bool(asBoolean(value))
|
|
157
|
+
case "i32":
|
|
158
|
+
return i32(Number(asIntegerish("i32", value)))
|
|
159
|
+
case "u64":
|
|
160
|
+
return u64(asIntegerish("u64", value))
|
|
161
|
+
case "u256":
|
|
162
|
+
return u256(asIntegerish("u256", value))
|
|
163
|
+
case "dec":
|
|
164
|
+
return dec(asString("dec", value))
|
|
165
|
+
case "bytes32":
|
|
166
|
+
return bytes32(asString("bytes32", value) as Hex)
|
|
167
|
+
case "key":
|
|
168
|
+
return key(asString("key", value) as Hex)
|
|
169
|
+
case "addr":
|
|
170
|
+
return addr(asString("addr", value))
|
|
171
|
+
case "str":
|
|
172
|
+
return strFromChain(asString("str", value))
|
|
173
|
+
case "bytes":
|
|
174
|
+
return makeValue("bytes", asString("bytes", value).toLowerCase() as Hex)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Narrows a type tag as the node spelled it.
|
|
180
|
+
*
|
|
181
|
+
* @throws {UnknownAttributeTypeError} If it names no type this SDK knows.
|
|
182
|
+
*/
|
|
183
|
+
export function asTypeTag(type: string): TypeTag {
|
|
184
|
+
// `Object.hasOwn`, not `in`: `in` walks the prototype chain, so "constructor" and "toString"
|
|
185
|
+
// would pass as type tags and then fall off the end of the exhaustive switch below as `undefined`.
|
|
186
|
+
if (typeof type !== "string" || !Object.hasOwn(TYPE_IDS, type)) {
|
|
187
|
+
throw new UnknownAttributeTypeError(type)
|
|
188
|
+
}
|
|
189
|
+
return type as TypeTag
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function asBoolean(value: unknown): boolean {
|
|
193
|
+
if (typeof value === "boolean") return value
|
|
194
|
+
// Tolerated because a node may render it as text; anything else is a protocol error.
|
|
195
|
+
if (value === "true" || value === "false") return value === "true"
|
|
196
|
+
throw new InvalidValueError("bool", value, "not a boolean")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function asString(tag: TypeTag, value: unknown): string {
|
|
200
|
+
if (typeof value !== "string") {
|
|
201
|
+
throw new InvalidValueError(tag, value, `expected a string, got ${typeof value}`)
|
|
202
|
+
}
|
|
203
|
+
return value
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A `0x` QUANTITY, a JSON number, or a decimal string — all of which name one integer. */
|
|
207
|
+
function asIntegerish(tag: TypeTag, value: unknown): bigint {
|
|
208
|
+
if (typeof value === "number") {
|
|
209
|
+
if (!Number.isInteger(value)) {
|
|
210
|
+
throw new InvalidValueError(tag, value, "not an integer")
|
|
211
|
+
}
|
|
212
|
+
return BigInt(value)
|
|
213
|
+
}
|
|
214
|
+
if (typeof value === "bigint") return value
|
|
215
|
+
if (typeof value === "string") {
|
|
216
|
+
if (/^0x[0-9a-fA-F]+$/.test(value)) return BigInt(value)
|
|
217
|
+
if (/^-?\d+$/.test(value)) return BigInt(value)
|
|
218
|
+
}
|
|
219
|
+
throw new InvalidValueError(tag, value, "not an integer")
|
|
220
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { InvalidValueError } from "./errors"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decimal places a `dec` value is scaled by. Fixed by the protocol, so any two decimals are
|
|
5
|
+
* directly comparable — and range-indexable — as plain integers.
|
|
6
|
+
*/
|
|
7
|
+
export const DECIMAL_SCALE = 18
|
|
8
|
+
|
|
9
|
+
/** 10 ** {@link DECIMAL_SCALE} — the scaling factor between a `dec` and its integer units. */
|
|
10
|
+
export const DECIMAL_UNIT = 10n ** BigInt(DECIMAL_SCALE)
|
|
11
|
+
|
|
12
|
+
/** The widest scaled value an `int256` holds. */
|
|
13
|
+
const INT256_MAX = 2n ** 255n - 1n
|
|
14
|
+
/** The narrowest scaled value an `int256` holds. */
|
|
15
|
+
const INT256_MIN = -(2n ** 255n)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A decimal literal: an optional sign, at least one integer digit, and an optional fraction of at
|
|
19
|
+
* most {@link DECIMAL_SCALE} digits. Exponents are not part of the grammar — `1e18` is a parse
|
|
20
|
+
* error rather than a silent reinterpretation.
|
|
21
|
+
*/
|
|
22
|
+
const DECIMAL_RE = /^([+-]?)(\d+)(?:\.(\d+))?$/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses a decimal literal into its scaled integer units.
|
|
26
|
+
*
|
|
27
|
+
* Excess fractional precision is an **error, never a rounding**: the protocol stores exactly 18
|
|
28
|
+
* decimal places, and silently dropping the 19th digit would make a stored value differ from the
|
|
29
|
+
* one that was written.
|
|
30
|
+
*
|
|
31
|
+
* @param input - The decimal literal, e.g. `"3.5"` or `"-0.000001"`.
|
|
32
|
+
* @returns The value scaled by 10 ** 18.
|
|
33
|
+
* @throws {InvalidValueError} If the literal is malformed, too precise, or out of `int256` range.
|
|
34
|
+
*/
|
|
35
|
+
export function decimalToUnits(input: string): bigint {
|
|
36
|
+
const match = DECIMAL_RE.exec(input)
|
|
37
|
+
if (!match) {
|
|
38
|
+
throw new InvalidValueError(
|
|
39
|
+
"dec",
|
|
40
|
+
input,
|
|
41
|
+
"not a decimal literal",
|
|
42
|
+
'Write it as sign, digits, and an optional "." with up to 18 fractional digits — e.g. ' +
|
|
43
|
+
'dec("3.5") or dec("-0.25"). Exponents such as "1e18" are not accepted.',
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const [, sign, whole, fraction = ""] = match
|
|
48
|
+
if (fraction.length > DECIMAL_SCALE) {
|
|
49
|
+
throw new InvalidValueError(
|
|
50
|
+
"dec",
|
|
51
|
+
input,
|
|
52
|
+
`${fraction.length} fractional digits exceeds the ${DECIMAL_SCALE} the protocol stores`,
|
|
53
|
+
"Round it yourself before storing — a dec is never rounded for you, so that what you " +
|
|
54
|
+
"write is exactly what is stored.",
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const digits = BigInt(whole + fraction.padEnd(DECIMAL_SCALE, "0"))
|
|
59
|
+
const units = sign === "-" ? -digits : digits
|
|
60
|
+
if (units > INT256_MAX || units < INT256_MIN) {
|
|
61
|
+
throw new InvalidValueError("dec", input, "exceeds the int256 range a dec is stored in")
|
|
62
|
+
}
|
|
63
|
+
return units
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Renders scaled integer units back as a canonical decimal string: no leading zeros, no trailing
|
|
68
|
+
* fractional zeros, no `-0`. Parsing the result yields the same units, so this is the inverse of
|
|
69
|
+
* {@link decimalToUnits} on every value it accepts.
|
|
70
|
+
*
|
|
71
|
+
* @param units - The value scaled by 10 ** 18.
|
|
72
|
+
* @returns The canonical decimal string, e.g. `"3.5"`.
|
|
73
|
+
* @throws {InvalidValueError} If the units are outside the `int256` range.
|
|
74
|
+
*/
|
|
75
|
+
export function unitsToDecimal(units: bigint): string {
|
|
76
|
+
if (units > INT256_MAX || units < INT256_MIN) {
|
|
77
|
+
throw new InvalidValueError("dec", units, "exceeds the int256 range a dec is stored in")
|
|
78
|
+
}
|
|
79
|
+
const negative = units < 0n
|
|
80
|
+
const magnitude = negative ? -units : units
|
|
81
|
+
const whole = magnitude / DECIMAL_UNIT
|
|
82
|
+
const fraction = (magnitude % DECIMAL_UNIT).toString().padStart(DECIMAL_SCALE, "0")
|
|
83
|
+
const trimmed = fraction.replace(/0+$/, "")
|
|
84
|
+
const sign = negative ? "-" : ""
|
|
85
|
+
return trimmed ? `${sign}${whole}.${trimmed}` : `${sign}${whole}`
|
|
86
|
+
}
|