@geeqdev/geeq-sdk 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/.idea/geeq_sdk.iml +12 -0
  2. package/.idea/modules.xml +8 -0
  3. package/.idea/vcs.xml +6 -0
  4. package/.nvmrc +1 -0
  5. package/CONTRIBUTING.md +20 -0
  6. package/example/.env.example +4 -0
  7. package/example/README.md +304 -0
  8. package/example/USAGE.md +74 -0
  9. package/example/index.html +12 -0
  10. package/example/package-lock.json +1557 -0
  11. package/example/package.json +25 -0
  12. package/example/scripts/browser-check.mjs +1421 -0
  13. package/example/scripts/match-derivation.mjs +65 -0
  14. package/example/src/App.vue +138 -0
  15. package/example/src/components/AuthorityPanel.vue +277 -0
  16. package/example/src/components/ChainPanel.vue +267 -0
  17. package/example/src/components/ChainState.vue +59 -0
  18. package/example/src/components/CopyButton.vue +25 -0
  19. package/example/src/components/CreateAssetPanel.vue +313 -0
  20. package/example/src/components/DigitalAssetsPanel.vue +68 -0
  21. package/example/src/components/EvidencePanel.vue +396 -0
  22. package/example/src/components/ExplorerPanel.vue +119 -0
  23. package/example/src/components/KeypairPanel.vue +182 -0
  24. package/example/src/components/PhrasePanel.vue +105 -0
  25. package/example/src/components/SocketWarning.vue +18 -0
  26. package/example/src/components/StepGate.vue +24 -0
  27. package/example/src/components/TransferPanel.vue +416 -0
  28. package/example/src/components/TxDeadline.vue +19 -0
  29. package/example/src/components/TxFee.vue +31 -0
  30. package/example/src/components/TxStatus.vue +105 -0
  31. package/example/src/composables/useSocketConnection.ts +80 -0
  32. package/example/src/env.d.ts +12 -0
  33. package/example/src/main.ts +5 -0
  34. package/example/src/pennies.ts +7 -0
  35. package/example/src/style.css +149 -0
  36. package/example/tsconfig.json +19 -0
  37. package/example/vite.config.ts +26 -0
  38. package/jest.config.js +18 -0
  39. package/package.json +1 -5
  40. package/scripts/copy-wasm.mjs +21 -0
  41. package/scripts/smoke.mjs +382 -0
  42. package/src/address/record-type.ts +77 -0
  43. package/src/address/wallet-address.ts +105 -0
  44. package/src/amount.ts +143 -0
  45. package/src/assert.ts +30 -0
  46. package/src/capnp/README.md +15 -0
  47. package/src/capnp/adminNetTx.capnp.ts +66 -0
  48. package/src/capnp/appLayBlock.capnp.ts +26 -0
  49. package/src/capnp/appLayLedger.capnp.ts +35 -0
  50. package/src/capnp/appLayRec.capnp.ts +159 -0
  51. package/src/capnp/chainStateView.capnp.ts +49 -0
  52. package/src/capnp/coinAcctRec.capnp.ts +193 -0
  53. package/src/capnp/genesisBlock.capnp.ts +56 -0
  54. package/src/capnp/hubContentStmt.capnp.ts +59 -0
  55. package/src/capnp/misc.capnp.ts +19 -0
  56. package/src/capnp/netActorRec.capnp.ts +64 -0
  57. package/src/capnp/nodeContentStmt.capnp.ts +62 -0
  58. package/src/capnp/nodePayloadList.capnp.ts +28 -0
  59. package/src/capnp/nodeTxList.capnp.ts +37 -0
  60. package/src/capnp/paramAcctRec.capnp.ts +669 -0
  61. package/src/capnp/payload.capnp.ts +261 -0
  62. package/src/capnp/proxyAcctRec.capnp.ts +259 -0
  63. package/src/capnp/relayContentStmt.capnp.ts +85 -0
  64. package/src/capnp/sysAcctRec.capnp.ts +47 -0
  65. package/src/capnp/userAppTx.capnp.ts +269 -0
  66. package/src/capnp/userValTx.capnp.ts +1999 -0
  67. package/src/capnp/valLayBlock.capnp.ts +93 -0
  68. package/src/capnp/valLayLedger.capnp.ts +80 -0
  69. package/src/chain/geeq-chain.ts +1094 -0
  70. package/src/chain/geeq-node.ts +105 -0
  71. package/src/chain/nonce-guard.ts +73 -0
  72. package/src/chain/select.ts +149 -0
  73. package/src/crypto/account-id.ts +83 -0
  74. package/src/crypto/keypair.ts +221 -0
  75. package/src/crypto/mnemonic.ts +69 -0
  76. package/src/encoding/base64.ts +58 -0
  77. package/src/encoding/hex.ts +42 -0
  78. package/src/errors.ts +122 -0
  79. package/src/index.ts +195 -0
  80. package/src/ledger/coin-accounts.ts +73 -0
  81. package/src/ledger/decode.ts +147 -0
  82. package/src/ledger/geeq-ledger.ts +184 -0
  83. package/src/ledger/net-actors.ts +129 -0
  84. package/src/ledger/nft-records.ts +49 -0
  85. package/src/ledger/params.ts +82 -0
  86. package/src/ledger/proxy-accounts.ts +51 -0
  87. package/src/net/discovery.ts +231 -0
  88. package/src/net/explorer.ts +123 -0
  89. package/src/net/read-ledger.ts +58 -0
  90. package/src/net/routes.ts +79 -0
  91. package/src/net/transport.ts +221 -0
  92. package/src/net/websocket.ts +94 -0
  93. package/src/runtime/bindings.ts +38 -0
  94. package/src/runtime/init.ts +49 -0
  95. package/src/runtime/wasm-loader.browser.ts +22 -0
  96. package/src/runtime/wasm-loader.node.ts +16 -0
  97. package/src/socket/geeq-socket.ts +827 -0
  98. package/src/socket/tracked-tx.ts +319 -0
  99. package/src/socket/tx-status.ts +131 -0
  100. package/src/tx/attestation.ts +222 -0
  101. package/src/tx/content.ts +82 -0
  102. package/src/tx/encode.ts +107 -0
  103. package/src/tx/expiry.ts +11 -0
  104. package/src/tx/fee.ts +31 -0
  105. package/src/tx/metadata.ts +100 -0
  106. package/src/tx/nft.ts +108 -0
  107. package/src/tx/proxy.ts +156 -0
  108. package/src/tx/transfer.ts +162 -0
  109. package/src/types.ts +503 -0
  110. package/src/wasm/README.md +22 -0
  111. package/src/wasm/browser/geeq_core.d.ts +1224 -0
  112. package/src/wasm/browser/geeq_core.js +6014 -0
  113. package/src/wasm/browser/geeq_core_bg.wasm +0 -0
  114. package/src/wasm/browser/geeq_core_bg.wasm.d.ts +447 -0
  115. package/src/wasm/nodejs/geeq_core.d.ts +753 -0
  116. package/src/wasm/nodejs/geeq_core.js +6016 -0
  117. package/src/wasm/nodejs/geeq_core_bg.wasm +0 -0
  118. package/src/wasm/nodejs/geeq_core_bg.wasm.d.ts +447 -0
  119. package/src/wasm/nodejs/package.json +3 -0
  120. package/tsconfig.build.json +8 -0
  121. package/tsconfig.json +20 -0
  122. package/tsup.config.ts +60 -0
@@ -0,0 +1,319 @@
1
+ import type { GeeqTxStatusEvent } from './tx-status'
2
+
3
+ /** The node committed it. */
4
+ export interface GeeqTxConfirmed {
5
+ readonly status: 'confirmed'
6
+ readonly id: string
7
+ /** The `valid` event that settled it. */
8
+ readonly event: GeeqTxStatusEvent
9
+ readonly at: Date
10
+ }
11
+
12
+ /**
13
+ * Why a transaction was rejected. Branch on this, never on {@link GeeqTxRejected.message}.
14
+ *
15
+ * - `invalid` — found wrong at commit, usually the nonce or the balance. The fee is still
16
+ * charged.
17
+ * - `incorrectly-included` — carried by a node the chain judged dishonest. Not your fault.
18
+ * - `nonsane` — the receiving node refused it and dropped it.
19
+ * - `refused` — the node answered the submission with an error status. It never entered
20
+ * a block and will produce no status event.
21
+ */
22
+ export type GeeqTxRejectionReason = 'invalid' | 'incorrectly-included' | 'nonsane' | 'refused'
23
+
24
+ export interface GeeqTxRejected {
25
+ readonly status: 'rejected'
26
+ readonly id: string
27
+ readonly reason: GeeqTxRejectionReason
28
+ /** The node's own words when it gave any. For display and support — never parse it. */
29
+ readonly message: string
30
+ /** The event that settled it. Undefined for a `refused` submission the socket never saw. */
31
+ readonly event: GeeqTxStatusEvent | undefined
32
+ /** The HTTP status of a `refused` submission, when the caller reported one. */
33
+ readonly httpStatus: number | undefined
34
+ readonly at: Date
35
+ }
36
+
37
+ /**
38
+ * No verdict, and none is coming to this handle.
39
+ *
40
+ * - `timeout` — nothing final arrived in time. **This is not a rejection**: statuses sent
41
+ * while the socket was reconnecting are lost, so it may well have settled. Read the
42
+ * ledger (has the account's nonce moved on?) before sending it again.
43
+ * - `closed` — the socket was disposed while it was pending.
44
+ * - `released` — the caller stopped tracking it.
45
+ */
46
+ export type GeeqTxUnknownReason = 'timeout' | 'closed' | 'released'
47
+
48
+ export interface GeeqTxUnknown {
49
+ readonly status: 'unknown'
50
+ readonly id: string
51
+ readonly reason: GeeqTxUnknownReason
52
+ readonly message: string
53
+ readonly at: Date
54
+ }
55
+
56
+ export type GeeqTxOutcome = GeeqTxConfirmed | GeeqTxRejected | GeeqTxUnknown
57
+
58
+ /** `pending` until an outcome, then the outcome's own status. Never goes back. */
59
+ export type GeeqTxState = 'pending' | GeeqTxOutcome['status']
60
+
61
+ /** What a submission refusal looked like, for {@link GeeqTrackedTx.refused}. */
62
+ export interface GeeqTxRefusal {
63
+ /** The HTTP status the node answered with. */
64
+ readonly status?: number
65
+ /** The node's `details`, or the body — whatever it said. */
66
+ readonly message?: string
67
+ }
68
+
69
+ /**
70
+ * Hand an error thrown by application code to the platform, without letting it stop the
71
+ * SDK delivering the same event to everyone else.
72
+ *
73
+ * @internal
74
+ */
75
+ export function reportListenerError(error: unknown): void {
76
+ const report = (globalThis as { reportError?: (error: unknown) => void }).reportError
77
+ if (typeof report === 'function') report(error)
78
+ else console.error(error)
79
+ }
80
+
81
+ /** @internal */
82
+ export interface TrackedTxInit {
83
+ readonly id: string
84
+ readonly timeoutMs: number
85
+ readonly nonsaneGraceMs: number
86
+ /** Called once, when it settles — the socket drops it from its table. */
87
+ readonly detach: () => void
88
+ }
89
+
90
+ /**
91
+ * One submitted transaction, followed to its verdict.
92
+ *
93
+ * Returned by {@link GeeqSocket.track}. It collapses the node's four verdicts, some of
94
+ * which arrive more than once and one of which is only usually final, into one of three
95
+ * outcomes an application can branch on — and it never rejects, because a rejected
96
+ * transaction is an answer, not a failure:
97
+ *
98
+ * ```ts
99
+ * const outcome = await tracked.settled()
100
+ * if (outcome.status === 'confirmed') …
101
+ * else if (outcome.status === 'rejected') show(outcome.reason, outcome.message)
102
+ * else checkLedgerBeforeRetrying() // 'unknown'
103
+ * ```
104
+ *
105
+ * In a reactive framework, keep it raw (Vue's `markRaw`) and re-read `status` and
106
+ * `events` from {@link onChange}.
107
+ */
108
+ export class GeeqTrackedTx {
109
+ /** The transaction's refHash, base64 — the same string status events carry. */
110
+ readonly id: string
111
+
112
+ // TS-private and non-enumerable, never `#private` — see GeeqNode for why.
113
+ private readonly received!: GeeqTxStatusEvent[]
114
+ private readonly listeners!: Set<(tx: GeeqTrackedTx) => void>
115
+ private readonly nonsaneGraceMs!: number
116
+ private readonly detach!: () => void
117
+ private readonly promise!: Promise<GeeqTxOutcome>
118
+ private readonly resolve!: (outcome: GeeqTxOutcome) => void
119
+ private current!: GeeqTxOutcome | undefined
120
+ private deadline!: ReturnType<typeof setTimeout> | null
121
+ private grace!: ReturnType<typeof setTimeout> | null
122
+
123
+ /** @internal */
124
+ constructor(init: TrackedTxInit) {
125
+ this.id = init.id
126
+
127
+ const hidden = (key: string, value: unknown, writable = false): void => {
128
+ Object.defineProperty(this, key, { value, enumerable: false, writable })
129
+ }
130
+ let resolve!: (outcome: GeeqTxOutcome) => void
131
+ const promise = new Promise<GeeqTxOutcome>((settle) => {
132
+ resolve = settle
133
+ })
134
+ hidden('received', [])
135
+ hidden('listeners', new Set())
136
+ hidden('nonsaneGraceMs', init.nonsaneGraceMs)
137
+ hidden('detach', init.detach)
138
+ hidden('promise', promise)
139
+ hidden('resolve', resolve)
140
+ hidden('current', undefined, true)
141
+ hidden('grace', null, true)
142
+ hidden(
143
+ 'deadline',
144
+ init.timeoutMs > 0
145
+ ? setTimeout(() => {
146
+ this.finish({
147
+ status: 'unknown',
148
+ id: this.id,
149
+ reason: 'timeout',
150
+ message:
151
+ `No final status for transaction ${this.id} within ${init.timeoutMs}ms. ` +
152
+ 'That is not a rejection: statuses sent while the socket was reconnecting ' +
153
+ 'are lost. Check the ledger — whether the account nonce has moved on — ' +
154
+ 'before sending it again.',
155
+ at: new Date(),
156
+ })
157
+ }, init.timeoutMs)
158
+ : null,
159
+ true,
160
+ )
161
+ }
162
+
163
+ /** `pending`, then `confirmed`, `rejected` or `unknown` — once, and for good. */
164
+ get status(): GeeqTxState {
165
+ return this.current?.status ?? 'pending'
166
+ }
167
+
168
+ /** The outcome, once there is one. */
169
+ get outcome(): GeeqTxOutcome | undefined {
170
+ return this.current
171
+ }
172
+
173
+ /** Every status event received for this transaction, oldest first. */
174
+ get events(): readonly GeeqTxStatusEvent[] {
175
+ return [...this.received]
176
+ }
177
+
178
+ /** Resolves with the outcome. Never rejects — see the class notes. */
179
+ settled(): Promise<GeeqTxOutcome> {
180
+ return this.promise
181
+ }
182
+
183
+ /**
184
+ * Call `listener` whenever an event arrives for this transaction or it settles.
185
+ *
186
+ * Returns the function that removes it. Read {@link status}, {@link events} and
187
+ * {@link outcome} inside; they are already up to date.
188
+ */
189
+ onChange(listener: (tx: GeeqTrackedTx) => void): () => void {
190
+ this.listeners.add(listener)
191
+ return () => {
192
+ this.listeners.delete(listener)
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Report that the node refused the submission — answered it with an error status.
198
+ *
199
+ * Settles it `rejected` at once. A refused transaction never reaches a block, so no
200
+ * status event would ever arrive to settle it, and waiting would only turn a clear
201
+ * answer into a timeout. Do **not** call this when the POST failed to arrive at all
202
+ * (a network error): the node may have it, so leave it tracked.
203
+ */
204
+ refused(refusal: GeeqTxRefusal = {}): void {
205
+ if (this.current !== undefined) return
206
+ const nonsane = this.received.find((event) => event.verdict === 'nonsane')
207
+ const said = refusal.message ?? nonsane?.error
208
+ this.finish({
209
+ status: 'rejected',
210
+ id: this.id,
211
+ reason: 'refused',
212
+ message:
213
+ said === undefined || said === ''
214
+ ? `The node refused transaction ${this.id}` +
215
+ (refusal.status === undefined ? '.' : ` with HTTP ${refusal.status}.`)
216
+ : said,
217
+ event: nonsane,
218
+ httpStatus: refusal.status,
219
+ at: new Date(),
220
+ })
221
+ }
222
+
223
+ /** Stop tracking. Settles `unknown` with reason `released` if it was still pending. */
224
+ release(): void {
225
+ this.abandon('released', `Tracking of transaction ${this.id} was released.`)
226
+ }
227
+
228
+ /** @internal Settle as unknown because the socket is going away, or on release. */
229
+ abandon(reason: 'closed' | 'released', message: string): void {
230
+ this.finish({ status: 'unknown', id: this.id, reason, message, at: new Date() })
231
+ }
232
+
233
+ /**
234
+ * @internal Take one status event for this transaction.
235
+ *
236
+ * The only place the protocol's rules live. First-event-wins would report false
237
+ * failures: a `nonsane` can be followed by a real verdict when a node carries its buffer
238
+ * over a restart, so it only settles if nothing final follows within the grace window.
239
+ * Everything after the outcome — a second node's copy, a duplicate — is ignored.
240
+ */
241
+ receive(event: GeeqTxStatusEvent): void {
242
+ if (this.current !== undefined) return
243
+ this.received.push(event)
244
+
245
+ if (event.verdict === 'nonsane') {
246
+ if (this.grace === null) {
247
+ this.grace = setTimeout(() => {
248
+ this.finish({
249
+ status: 'rejected',
250
+ id: this.id,
251
+ reason: 'nonsane',
252
+ message: event.error ?? 'The node refused this transaction.',
253
+ event,
254
+ httpStatus: undefined,
255
+ at: new Date(),
256
+ })
257
+ }, this.nonsaneGraceMs)
258
+ }
259
+ this.notify()
260
+ return
261
+ }
262
+
263
+ if (event.verdict === 'valid') {
264
+ this.finish({ status: 'confirmed', id: this.id, event, at: new Date() })
265
+ return
266
+ }
267
+
268
+ this.finish({
269
+ status: 'rejected',
270
+ id: this.id,
271
+ reason: event.verdict === 'invalid' ? 'invalid' : 'incorrectly-included',
272
+ message:
273
+ event.error ??
274
+ (event.verdict === 'invalid'
275
+ ? 'The chain found this transaction invalid when committing the block.'
276
+ : 'This transaction was carried by a node the chain judged dishonest.'),
277
+ event,
278
+ httpStatus: undefined,
279
+ at: new Date(),
280
+ })
281
+ }
282
+
283
+ private finish(outcome: GeeqTxOutcome): void {
284
+ if (this.current !== undefined) return
285
+ this.current = Object.freeze(outcome)
286
+ if (this.deadline !== null) clearTimeout(this.deadline)
287
+ if (this.grace !== null) clearTimeout(this.grace)
288
+ this.deadline = null
289
+ this.grace = null
290
+ this.detach()
291
+ this.resolve(this.current)
292
+ this.notify()
293
+ }
294
+
295
+ private notify(): void {
296
+ for (const listener of [...this.listeners]) {
297
+ try {
298
+ listener(this)
299
+ } catch (error) {
300
+ reportListenerError(error)
301
+ }
302
+ }
303
+ }
304
+
305
+ toJSON(): Record<string, unknown> {
306
+ return {
307
+ id: this.id,
308
+ status: this.status,
309
+ events: this.received.map((event) => event.verdict),
310
+ ...(this.current?.status === 'rejected' || this.current?.status === 'unknown'
311
+ ? { reason: this.current.reason }
312
+ : {}),
313
+ }
314
+ }
315
+
316
+ toString(): string {
317
+ return `GeeqTrackedTx(${this.id}, ${this.status})`
318
+ }
319
+ }
@@ -0,0 +1,131 @@
1
+ import { toBase64 } from '../encoding/base64'
2
+
3
+ /**
4
+ * The four verdicts a node reports for a transaction, by their protocol names.
5
+ *
6
+ * - `valid` — committed in a block. Final.
7
+ * - `invalid` — placed in a block's invalid list, typically a nonce or solvency failure
8
+ * found at commit. Final, and **the sender is still charged the fee**.
9
+ * - `incorrectlyIncluded` — carried by a node the chain judged dishonest. Final; not the
10
+ * sender's fault.
11
+ * - `nonsane` — refused by the receiving node, at intake (alongside an HTTP 400) or at block
12
+ * selection (after a 200). The node drops it, so it is final in practice — but a node
13
+ * restarting can carry its buffer forward, and then a later verdict follows.
14
+ *
15
+ * From geeq_node_core `UutStatuses` (actors/interfaces.ts).
16
+ */
17
+ export type GeeqTxVerdict = 'nonsane' | 'incorrectlyIncluded' | 'invalid' | 'valid'
18
+
19
+ /** The wire carries names, but the node's own enum is numbered; accept either. */
20
+ const VERDICTS: readonly GeeqTxVerdict[] = [
21
+ 'nonsane',
22
+ 'incorrectlyIncluded',
23
+ 'invalid',
24
+ 'valid',
25
+ ]
26
+
27
+ /**
28
+ * One `uut_status` event, normalised.
29
+ *
30
+ * Every field is what the node sent, renamed only where the wire name misleads. Nothing
31
+ * is inferred: in particular `vlbBlock` is passed through as reported and not treated as
32
+ * the inclusion block, because on current nodes it is one ahead of it for `valid` and
33
+ * `invalid` events.
34
+ */
35
+ export interface GeeqTxStatusEvent {
36
+ /**
37
+ * The transaction's refHash, standard base64 — compare it with `transactionIdOf(tx)`.
38
+ * Always the transaction hash, never the payload hash.
39
+ */
40
+ readonly id: string
41
+ readonly verdict: GeeqTxVerdict
42
+ /** The node's own boolean: true exactly when `verdict` is `valid`. */
43
+ readonly isValid: boolean
44
+ /** The node's reason, verbatim. Empty when valid. Show it; never parse it. */
45
+ readonly error: string | undefined
46
+ /** The transaction's own target block, as the node reports it. */
47
+ readonly blockNumber: number | undefined
48
+ /** The node's `vlbBlock`, as reported. See the note above before relying on it. */
49
+ readonly vlbBlock: number | undefined
50
+ /**
51
+ * `outgoing` on the sender's copy, `incoming` on the receiver's, when the node says —
52
+ * older nodes do not. Do not use it to tell your own transactions apart: match on
53
+ * {@link id} instead.
54
+ */
55
+ readonly direction: 'incoming' | 'outgoing' | undefined
56
+ /** The signer's base64 public key. */
57
+ readonly signer: string | undefined
58
+ /** When the node stamped it. Second precision. */
59
+ readonly createdAt: Date | undefined
60
+ /** The event's payload exactly as it arrived, for logging and support. */
61
+ readonly raw: unknown
62
+ }
63
+
64
+ function optionalNumber(value: unknown): number | undefined {
65
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
66
+ }
67
+
68
+ function verdictOf(value: unknown): GeeqTxVerdict | undefined {
69
+ if (typeof value === 'number') return VERDICTS[value]
70
+ if (typeof value === 'string') {
71
+ return VERDICTS.find((verdict) => verdict === value)
72
+ }
73
+ return undefined
74
+ }
75
+
76
+ /** The node renders timestamps as `Date:<Date#toString()>`. */
77
+ function dateOf(value: unknown): Date | undefined {
78
+ if (typeof value !== 'string') return undefined
79
+ const parsed = Date.parse(value.replace(/^Date:/u, ''))
80
+ return Number.isNaN(parsed) ? undefined : new Date(parsed)
81
+ }
82
+
83
+ /**
84
+ * Read a `uut_status` payload, or `undefined` when it is not one this SDK understands.
85
+ *
86
+ * Tolerant of the shapes nodes have actually sent: `publicKeys` as an array or the older
87
+ * single `publicKey`, the verdict as a name or as the node's enum number.
88
+ *
89
+ * @internal
90
+ */
91
+ export function parseTxStatus(payload: unknown): GeeqTxStatusEvent | undefined {
92
+ if (payload === null || typeof payload !== 'object') return undefined
93
+ const fields = payload as Record<string, unknown>
94
+
95
+ const id = fields.refHash
96
+ const verdict = verdictOf(fields.status)
97
+ if (typeof id !== 'string' || id === '' || verdict === undefined) return undefined
98
+
99
+ const keys = fields.publicKeys
100
+ const signer = Array.isArray(keys)
101
+ ? keys.find((key): key is string => typeof key === 'string')
102
+ : typeof fields.publicKey === 'string'
103
+ ? fields.publicKey
104
+ : undefined
105
+
106
+ const direction =
107
+ fields.type === 'incoming' || fields.type === 'outgoing' ? fields.type : undefined
108
+
109
+ return Object.freeze({
110
+ id,
111
+ verdict,
112
+ isValid: typeof fields.isValid === 'boolean' ? fields.isValid : verdict === 'valid',
113
+ error: typeof fields.error === 'string' && fields.error !== '' ? fields.error : undefined,
114
+ blockNumber: optionalNumber(fields.blockNumber),
115
+ vlbBlock: optionalNumber(fields.vlbBlock),
116
+ direction,
117
+ signer,
118
+ createdAt: dateOf(fields.createdAt),
119
+ raw: payload,
120
+ })
121
+ }
122
+
123
+ /**
124
+ * A transaction's id as status events name it: its `transactionHash`, in base64.
125
+ *
126
+ * Works on anything the SDK builds — an attestation or a transfer — because each already
127
+ * carries the hash its kind is identified by, and the two kinds are hashed differently.
128
+ */
129
+ export function transactionIdOf(tx: { readonly transactionHash: Uint8Array }): string {
130
+ return toBase64(tx.transactionHash)
131
+ }
@@ -0,0 +1,222 @@
1
+ import { blake3 } from '@noble/hashes/blake3'
2
+ import { Message } from 'capnp-ts'
3
+ import {
4
+ AttestationSubgroup as CapnpAttestationSubgroup,
5
+ Payload as CapnpPayload,
6
+ } from '../capnp/payload.capnp'
7
+ import { UserAppTx as CapnpUserAppTx } from '../capnp/userAppTx.capnp'
8
+
9
+ import { toBase64 } from '../encoding/base64'
10
+ import { assertMetadataWithinLimits, encodeMetadata } from './metadata'
11
+ import { ATTEST_HASH_BYTES, assertAttestHash } from './content'
12
+ import {
13
+ REF_HASH_BYTES,
14
+ assertChainState,
15
+ assertKeypair,
16
+ flatten,
17
+ packed,
18
+ pinning,
19
+ resolveChainState,
20
+ } from './encode'
21
+ import { feeFor } from './fee'
22
+ import {
23
+ WasmAttestationSubgroupData,
24
+ WasmPayload,
25
+ WasmUserAppTx,
26
+ WasmUserAppTxData,
27
+ } from '../runtime/bindings'
28
+ import { assertReady, initGeeq } from '../runtime/init'
29
+ import { ACCOUNT_ID_BYTES, PUBLIC_KEY_BYTES } from '../crypto/account-id'
30
+ import type { AttestationTx, AttestationTxInput, GeeqKeypair } from '../types'
31
+
32
+ /**
33
+ * Build and sign an attestation subgroup: a hash, its labels, and the account vouching
34
+ * for them. An attestation and an NFT mint carry the same subgroup; it names the signer's
35
+ * own account in one and the minting proxy in the other.
36
+ *
37
+ * Also returns the signature-excluded flatten — what was signed, and what an NFT's id is
38
+ * the hash of.
39
+ *
40
+ * @internal
41
+ */
42
+ export function signAttestationSubgroup(input: {
43
+ readonly keypair: GeeqKeypair
44
+ readonly attestHash: Uint8Array
45
+ readonly metadata: readonly Uint8Array[]
46
+ readonly chainNumber: number
47
+ readonly accountId: Uint8Array
48
+ }): { subgroup: CapnpAttestationSubgroup; flattened: Uint8Array } {
49
+ const { keypair, attestHash, metadata } = input
50
+
51
+ const message = new Message()
52
+ const subgroup = message.initRoot(CapnpAttestationSubgroup)
53
+ const data = subgroup.initData()
54
+ data.setChainNum(input.chainNumber)
55
+ data.initAttestHash(ATTEST_HASH_BYTES)
56
+ data.getAttestHash().copyBuffer(attestHash)
57
+ data.initAcctNumVal(ACCOUNT_ID_BYTES)
58
+ data.getAcctNumVal().copyBuffer(input.accountId)
59
+ data.initPubKey(PUBLIC_KEY_BYTES)
60
+ data.getPubKey().copyBuffer(keypair.publicKey)
61
+
62
+ data.initMetadata(metadata.length)
63
+ const metadataList = data.getMetadata()
64
+ metadata.forEach((item, index) => {
65
+ const entry = metadataList.get(index)
66
+ // Subgroup positions are 1-based throughout the protocol, so the first label is
67
+ // position 1. The node sorts by this field on read, which is what preserves the
68
+ // order the caller gave.
69
+ entry.setPosition(index + 1)
70
+ entry.initItem(item.length)
71
+ entry.getItem().copyBuffer(item)
72
+ })
73
+
74
+ // The subgroup is signed over its data alone, before the signature field exists.
75
+ const flattened = flatten(
76
+ () => WasmAttestationSubgroupData.fromAttestationSubgroupCapnp(packed(message)),
77
+ 'attestation subgroup',
78
+ )
79
+ const signature = keypair.sign(flattened)
80
+ subgroup.initSignature(signature.length)
81
+ subgroup.getSignature().copyBuffer(signature)
82
+
83
+ return { subgroup, flattened }
84
+ }
85
+
86
+ /**
87
+ * A payload as it is submitted, the hash its transaction references it by, and its
88
+ * flattened size, which the fee charges for.
89
+ *
90
+ * @internal
91
+ */
92
+ export function packPayload(message: Message): {
93
+ payload: string
94
+ refHashPayload: Uint8Array
95
+ bytes: number
96
+ } {
97
+ const flattened = flatten(() => WasmPayload.fromPayloadCapnp(packed(message)), 'payload')
98
+ return {
99
+ payload: toBase64(packed(message)),
100
+ refHashPayload: blake3(flattened, { dkLen: REF_HASH_BYTES }),
101
+ bytes: flattened.length,
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Sign a built application-layer transaction and work out the id the node files it under.
107
+ * Every application-layer transaction — an attestation, an NFT mint — ends this way.
108
+ *
109
+ * @internal
110
+ */
111
+ export function signAppTx(
112
+ message: Message,
113
+ tx: CapnpUserAppTx,
114
+ keypair: GeeqKeypair,
115
+ ): { transaction: string; transactionHash: Uint8Array; bytes: number } {
116
+ // Signed over the signature-EXCLUDED flatten. The node verifies against exactly this
117
+ // form: its flatten worker swaps in the `…Data` class whenever the signature is left
118
+ // out, and its signature check asks for the flatten without one.
119
+ const flattened = flatten(
120
+ () => WasmUserAppTxData.fromUserAppTxCapnp(packed(message)),
121
+ 'transaction',
122
+ )
123
+ const signature = keypair.sign(flattened)
124
+ tx.initSignature(signature.length)
125
+ tx.getSignature().copyBuffer(signature)
126
+
127
+ // The chain identifies a transaction by the hash of its signature-INCLUDED flatten —
128
+ // that is the value its status events carry. Hashing the signing preimage instead
129
+ // yields a plausible-looking 32 bytes that no node has ever heard of, so this has to
130
+ // happen after the signature is attached, and through the non-Data class. The same
131
+ // flatten is what the fee charges for.
132
+ const signed = flatten(
133
+ () => WasmUserAppTx.fromUserAppTxCapnp(packed(message)),
134
+ 'signed transaction',
135
+ )
136
+
137
+ return {
138
+ transaction: toBase64(packed(message)),
139
+ transactionHash: blake3(signed, { dkLen: REF_HASH_BYTES }),
140
+ bytes: signed.length,
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Build and sign an attestation transaction. Synchronous; requires a loaded engine.
146
+ *
147
+ * This function is offline: it opens no sockets and reads no ledger. It needs chain
148
+ * state that only a node can supply, so either pass the four values yourself or let
149
+ * {@link GeeqChain} read them and hand you a `chainState`.
150
+ *
151
+ * The transaction is signed but **not submitted**. Post
152
+ * {@link AttestationTx.transaction} and {@link AttestationTx.payload} to the node named
153
+ * by {@link AttestationTx.nodeUrl}.
154
+ */
155
+ export function createAttestationTxSync(input: AttestationTxInput): AttestationTx {
156
+ assertReady('createAttestationTxSync')
157
+
158
+ const { keypair, attestHash } = input
159
+ const chainState = resolveChainState(input)
160
+ const { chainNumber, blockNumber, nodeNumber, nonce } = chainState
161
+
162
+ assertKeypair(keypair)
163
+ assertAttestHash(attestHash)
164
+ assertChainState(chainState)
165
+
166
+ const metadata = encodeMetadata(input.metadata)
167
+ assertMetadataWithinLimits(metadata, chainState.metadataLimits, { chainNumber, blockNumber })
168
+
169
+ // 1. The attestation subgroup: what is being attested, and by whom. The account's
170
+ // identifying record number is the BLAKE3 of its public key, which is exactly the
171
+ // account id the keypair already carries.
172
+ const { subgroup } = signAttestationSubgroup({
173
+ keypair,
174
+ attestHash,
175
+ metadata,
176
+ chainNumber,
177
+ accountId: keypair.accountId,
178
+ })
179
+
180
+ // 2. The payload carries the signed subgroup.
181
+ const payloadMessage = new Message()
182
+ const payloadRoot = payloadMessage.initRoot(CapnpPayload)
183
+ payloadRoot.initTxType().initAttestCommit()
184
+ payloadRoot.getTxType().getAttestCommit().setAttestationSubgroup(subgroup)
185
+ const { payload, refHashPayload, bytes: payloadBytes } = packPayload(payloadMessage)
186
+
187
+ // 3. The transaction references the payload by hash rather than carrying it, and
188
+ // names the block and node that should process it.
189
+ const txMessage = new Message()
190
+ const tx = txMessage.initRoot(CapnpUserAppTx)
191
+ const txData = tx.getData()
192
+ txData.initTxType().initAttestCommit()
193
+ txData.getTxType().getAttestCommit().initAcctNumVal().setNone()
194
+ txData.setChainNum(chainNumber)
195
+ txData.setBlockNum(blockNumber)
196
+ txData.setNodeNum(nodeNumber)
197
+ txData.setNonce(nonce)
198
+ txData.initRefHashPayload(refHashPayload.length)
199
+ txData.getRefHashPayload().copyBuffer(refHashPayload)
200
+ txData.initPubKey(PUBLIC_KEY_BYTES)
201
+ txData.getPubKey().copyBuffer(keypair.publicKey)
202
+
203
+ const { transaction, transactionHash, bytes } = signAppTx(txMessage, tx, keypair)
204
+
205
+ return Object.freeze({
206
+ transaction,
207
+ payload,
208
+ refHashPayload,
209
+ transactionHash,
210
+ publicKey: keypair.publicKey,
211
+ // An attestation sends no coin, so it pays only for its bytes: the transaction's and
212
+ // the payload's.
213
+ fee: feeFor(bytes + payloadBytes, 0n, chainState.geeqNorm),
214
+ ...pinning(chainState),
215
+ })
216
+ }
217
+
218
+ /** Build and sign an attestation transaction, loading the engine if needed. */
219
+ export async function createAttestationTx(input: AttestationTxInput): Promise<AttestationTx> {
220
+ await initGeeq()
221
+ return createAttestationTxSync(input)
222
+ }