@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,68 @@
1
+ <script setup lang="ts">
2
+ import { ref, shallowRef, watch } from 'vue'
3
+ import { toBase64, toHex } from '@geeqdev/geeq-sdk'
4
+ import type { GeeqChain, GeeqKeypair, GeeqNft } from '@geeqdev/geeq-sdk'
5
+ import CopyButton from './CopyButton.vue'
6
+
7
+ const props = defineProps<{
8
+ keypair: GeeqKeypair | null
9
+ chain: GeeqChain | null
10
+ }>()
11
+
12
+ /** This account's digital assets as the ledger last listed them, or null before the first look. */
13
+ const assets = shallowRef<readonly GeeqNft[] | null>(null)
14
+ const loading = ref(false)
15
+ const loadError = ref('')
16
+
17
+ watch([() => props.keypair, () => props.chain], () => (assets.value = null))
18
+
19
+ async function load() {
20
+ if (props.keypair === null || props.chain === null) return
21
+
22
+ loading.value = true
23
+ loadError.value = ''
24
+ try {
25
+ // A fresh read: a digital asset just created or sent shows only once a block has
26
+ // confirmed it.
27
+ assets.value = await props.chain.findNfts({ owner: props.keypair, maxAgeMs: 0 })
28
+ } catch (error) {
29
+ loadError.value = (error as Error).message
30
+ } finally {
31
+ loading.value = false
32
+ }
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <section class="card">
38
+ <h2><span class="step">8</span>Your digital assets</h2>
39
+
40
+ <p class="hint" style="margin-top: 0">
41
+ The digital assets this account holds, found in the ledger from its key alone, each with
42
+ the hash and labels it was created with. Send them in step 5.
43
+ </p>
44
+
45
+ <div class="row">
46
+ <button :disabled="!keypair || !chain || loading" data-testid="get-assets" @click="load">
47
+ {{ loading ? 'Reading ledger…' : assets ? 'Refresh' : 'Get my digital assets' }}
48
+ </button>
49
+ </div>
50
+ <div v-if="loadError" class="warn">{{ loadError }}</div>
51
+
52
+ <template v-if="assets">
53
+ <p v-if="assets.length === 0" class="hint" data-testid="no-assets">
54
+ None yet. A new digital asset shows here once a block has confirmed it.
55
+ </p>
56
+ <div v-for="asset in assets" :key="toBase64(asset.id)" class="field" data-testid="asset">
57
+ <div class="k">{{ asset.metadata.join(', ') || 'No labels' }}</div>
58
+ <div class="row">
59
+ <code class="v" style="flex: 1 1 auto">{{ toBase64(asset.id) }}</code>
60
+ <CopyButton :value="toBase64(asset.id)" />
61
+ </div>
62
+ <div v-if="asset.attestHash" class="hint" style="margin-top: 4px">
63
+ content hash {{ toHex(asset.attestHash) }}
64
+ </div>
65
+ </div>
66
+ </template>
67
+ </section>
68
+ </template>
@@ -0,0 +1,396 @@
1
+ <script setup lang="ts">
2
+ import { computed, markRaw, ref, shallowRef, watch } from 'vue'
3
+ import {
4
+ GeeqError,
5
+ attestHashOf,
6
+ attestationSubmitBody,
7
+ fromHex,
8
+ inspectMetadata,
9
+ submitRoute,
10
+ toBase64,
11
+ toHex,
12
+ } from '@geeqdev/geeq-sdk'
13
+ import type {
14
+ AttestationTx,
15
+ GeeqChain,
16
+ GeeqKeypair,
17
+ GeeqTrackedTx,
18
+ } from '@geeqdev/geeq-sdk'
19
+ import type { SocketConnection } from '../composables/useSocketConnection'
20
+ import ChainState from './ChainState.vue'
21
+ import CopyButton from './CopyButton.vue'
22
+ import SocketWarning from './SocketWarning.vue'
23
+ import TxDeadline from './TxDeadline.vue'
24
+ import TxFee from './TxFee.vue'
25
+ import TxStatus from './TxStatus.vue'
26
+
27
+ const props = defineProps<{
28
+ keypair: GeeqKeypair | null
29
+ chain: GeeqChain | null
30
+ connection: SocketConnection
31
+ }>()
32
+
33
+ type Source = 'hash' | 'file'
34
+ const source = ref<Source>('hash')
35
+ const hashInput = ref('')
36
+ const fileName = ref('')
37
+ // The SDK takes a File directly — a File is a Blob — so there is nothing to read here.
38
+ const file = ref<File | null>(null)
39
+ const metadataInput = ref('')
40
+
41
+ const tx = ref<AttestationTx | null>(null)
42
+ const building = ref(false)
43
+ const buildError = ref<{ code: string; message: string } | null>(null)
44
+ const submitting = ref(false)
45
+ const submitError = ref('')
46
+ const submitResult = ref('')
47
+ /** Set when the check before submitting found the built transaction too old. */
48
+ const expired = ref(false)
49
+ /**
50
+ * The last submission, followed to its verdict. Not cleared when the inputs change: the
51
+ * transaction is already out, and its answer is still worth showing.
52
+ */
53
+ const tracked = shallowRef<GeeqTrackedTx | null>(null)
54
+ /** Why the last submission is not being followed, when it is not. */
55
+ const notTracked = ref('')
56
+
57
+ /** Every SDK failure carries a stable code; branch on that, never on the message. */
58
+ function asGeeq(error: unknown): { code: string; message: string } {
59
+ if (error instanceof GeeqError) return { code: error.code, message: error.message }
60
+ const e = error as { name?: string; code?: string; message?: string }
61
+ if (e?.name === 'GeeqError' && typeof e.code === 'string') {
62
+ return { code: e.code, message: e.message ?? '' }
63
+ }
64
+ return { code: 'unknown', message: (error as Error)?.message ?? String(error) }
65
+ }
66
+
67
+ /** Labels are kept in the order given; blank entries are dropped, not sent empty. */
68
+ const metadata = computed(() =>
69
+ metadataInput.value
70
+ .split(',')
71
+ .map((item) => item.trim())
72
+ .filter((item) => item !== ''),
73
+ )
74
+
75
+ /** The pasted hash as bytes, or null until it is 32 bytes of hex. */
76
+ const pastedHash = computed(() => {
77
+ try {
78
+ const bytes = fromHex(hashInput.value.trim())
79
+ return bytes.length === 32 ? bytes : null
80
+ } catch {
81
+ return null
82
+ }
83
+ })
84
+
85
+ /**
86
+ * The file's digest, computed as soon as it is chosen.
87
+ *
88
+ * `attestHashOf` needs no engine, so this works while the WebAssembly bundle is still
89
+ * downloading. It is async only because a File has to be read; the sequence guard keeps a
90
+ * slow large file from overwriting the answer for one chosen after it.
91
+ */
92
+ const fileHash = ref<Uint8Array | null>(null)
93
+ let hashSequence = 0
94
+ watch(file, async (value) => {
95
+ const ticket = (hashSequence += 1)
96
+ fileHash.value = null
97
+ const next = value === null ? null : await attestHashOf(value)
98
+ if (ticket === hashSequence) fileHash.value = next
99
+ })
100
+
101
+ /** What goes into the transaction: the pasted hash, or the file's. */
102
+ const evidenceHash = computed(() => (source.value === 'hash' ? pastedHash.value : fileHash.value))
103
+
104
+ /**
105
+ * What the last build was pinned to.
106
+ *
107
+ * There is deliberately nothing to show before one: the ledger is only read when a
108
+ * transaction is actually being signed, because its values go stale within a few blocks.
109
+ */
110
+ const chainState = computed(() => tx.value?.chainState ?? null)
111
+ const limits = computed(() => chainState.value?.metadataLimits)
112
+ /** The SDK measures metadata the same way the chain does — UTF-8 bytes, not characters. */
113
+ const metadataReport = computed(() => inspectMetadata(metadata.value, limits.value))
114
+
115
+ const canBuild = computed(
116
+ () => props.keypair !== null && evidenceHash.value !== null && props.chain !== null,
117
+ )
118
+
119
+ function onFile(event: Event) {
120
+ const chosen = (event.target as HTMLInputElement).files?.[0]
121
+ if (!chosen) {
122
+ fileName.value = ''
123
+ file.value = null
124
+ return
125
+ }
126
+ fileName.value = `${chosen.name} · ${chosen.size.toLocaleString()} bytes`
127
+ file.value = chosen
128
+ }
129
+
130
+ // A built transaction is pinned to a block and a nonce, so anything that changes what it
131
+ // says invalidates it. Drop it rather than let a stale one be submitted.
132
+ watch([evidenceHash, metadata, () => props.keypair, () => props.chain], () => {
133
+ tx.value = null
134
+ submitResult.value = ''
135
+ submitError.value = ''
136
+ expired.value = false
137
+ })
138
+
139
+ async function build() {
140
+ const keypair = props.keypair
141
+ const active = props.chain
142
+ const hash = evidenceHash.value
143
+ if (keypair === null || active === null || hash === null) return
144
+
145
+ building.value = true
146
+ buildError.value = null
147
+ tx.value = null
148
+ expired.value = false
149
+ try {
150
+ // A keypair and the hash. The ledger read happens in here.
151
+ tx.value = await active.createAttestation({
152
+ keypair,
153
+ attestHash: hash,
154
+ metadata: metadata.value,
155
+ })
156
+ } catch (error) {
157
+ buildError.value = asGeeq(error)
158
+ } finally {
159
+ building.value = false
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The SDK does not POST — that stays here — but it does listen: the node socket from step
165
+ * 3 tracks the transaction, which is subscribed before it is sent. The transaction names
166
+ * the node that must process it, so it goes to `tx.nodeUrl` and nowhere else: the node
167
+ * compares that number to its own with strict equality.
168
+ */
169
+ async function submit() {
170
+ const built = tx.value
171
+ const target = built?.nodeUrl ?? props.chain?.endpoint
172
+ if (built === null || target === undefined || props.chain === null) return
173
+
174
+ submitting.value = true
175
+ submitError.value = ''
176
+ submitResult.value = ''
177
+ tracked.value = null
178
+ notTracked.value = ''
179
+ try {
180
+ // It may have waited, and a node refuses one the chain has moved too far past.
181
+ if (await props.chain.isExpired(built)) {
182
+ expired.value = true
183
+ return
184
+ }
185
+
186
+ // Listen before sending: a verdict that arrives before anyone has subscribed is lost.
187
+ // Without a socket it is still sent, only with no one listening for the answer.
188
+ const socket = props.connection.socket
189
+ if (socket === null) {
190
+ notTracked.value = 'The node socket is not open.'
191
+ } else {
192
+ try {
193
+ tracked.value = markRaw(await socket.track(built))
194
+ } catch (e) {
195
+ notTracked.value = `Could not subscribe for its verdict: ${(e as Error).message}`
196
+ }
197
+ }
198
+
199
+ const response = await fetch(submitRoute(target), {
200
+ method: 'POST',
201
+ headers: { 'content-type': 'application/json' },
202
+ body: attestationSubmitBody(built.transaction, built.payload),
203
+ })
204
+ const body = await response.text()
205
+ if (!response.ok) {
206
+ // A node sends no status for a submission it refused, so the tracker has to be told.
207
+ tracked.value?.refused({ status: response.status, message: body.slice(0, 400) })
208
+ submitError.value = `Node replied ${response.status} ${response.statusText}: ${body.slice(0, 400)}`
209
+ return
210
+ }
211
+ submitResult.value = body === '' ? `${response.status} (empty body)` : body.slice(0, 1000)
212
+ } catch (e) {
213
+ // The POST may still have arrived, so a tracked transaction stays tracked.
214
+ submitError.value = `Could not reach the node — ${(e as Error).message}.`
215
+ } finally {
216
+ submitting.value = false
217
+ }
218
+ }
219
+ </script>
220
+
221
+ <template>
222
+ <section class="card">
223
+ <h2><span class="step">4</span>Submit evidence</h2>
224
+
225
+ <p class="hint" style="margin-top: 0">
226
+ Submitting evidence publishes a <strong>hash</strong> of something to a chain. The content
227
+ itself never leaves this tab — only its 32-byte BLAKE3 digest goes into the
228
+ transaction. Paste a digest you already have, or choose a file to hash it here.
229
+ </p>
230
+
231
+ <div class="row">
232
+ <button :class="{ primary: source === 'hash' }" @click="source = 'hash'">Paste hash</button>
233
+ <button :class="{ primary: source === 'file' }" @click="source = 'file'">File</button>
234
+ </div>
235
+
236
+ <template v-if="source === 'hash'">
237
+ <div class="row" style="margin-top: 12px">
238
+ <span class="ctl grow">
239
+ <label for="evidence-hash">Hash</label>
240
+ <input
241
+ id="evidence-hash"
242
+ v-model="hashInput"
243
+ type="text"
244
+ spellcheck="false"
245
+ placeholder="hex, 64 digits"
246
+ data-testid="evidence-hash-input"
247
+ />
248
+ </span>
249
+ </div>
250
+ <p v-if="hashInput.trim() && !pastedHash" class="warn" data-testid="evidence-hash-error">
251
+ An evidence hash is 32 bytes: 64 hex digits, with or without a leading <code>0x</code>.
252
+ </p>
253
+ </template>
254
+ <div v-else class="row" style="margin-top: 12px">
255
+ <input type="file" @change="onFile" />
256
+ <span v-if="fileName" class="hint">{{ fileName }}</span>
257
+ </div>
258
+
259
+ <div class="field" style="margin-top: 12px">
260
+ <div class="k">Evidence hash · BLAKE3 · 32 bytes · what goes into the transaction</div>
261
+ <div v-if="evidenceHash" class="v" data-testid="evidence-hash">{{ toHex(evidenceHash) }}</div>
262
+ <div v-else class="v" style="color: var(--muted)">Paste a hash or choose a file above.</div>
263
+ </div>
264
+
265
+ <div class="row" style="margin-top: 16px">
266
+ <span class="ctl grow">
267
+ <label for="meta">Metadata</label>
268
+ <input id="meta" v-model="metadataInput" type="text" placeholder="comma, separated, labels" />
269
+ </span>
270
+ <span
271
+ v-if="limits"
272
+ class="hint"
273
+ :class="{ over: !metadataReport.withinLimits }"
274
+ data-testid="meta-limits"
275
+ >
276
+ {{ metadataReport.count }}/{{ limits.maxEntries }} labels ·
277
+ {{ limits.maxBytesPerEntry }} bytes each
278
+ </span>
279
+ </div>
280
+ <p v-if="limits && !metadataReport.withinLimits" class="warn" data-testid="meta-over">
281
+ <strong>This chain would reject that metadata.</strong>
282
+ <template v-if="metadataReport.tooManyEntries">
283
+ It allows {{ limits.maxEntries }} labels and there are {{ metadataReport.count }}.
284
+ </template>
285
+ <template v-for="entry in metadataReport.entries" :key="entry.index">
286
+ <template v-if="entry.overLimit">
287
+ Label {{ entry.index + 1 }} is {{ entry.bytes }} bytes, over the
288
+ {{ limits.maxBytesPerEntry }}-byte limit — measured as UTF-8, so non-ASCII
289
+ characters cost more than one each.
290
+ </template>
291
+ </template>
292
+ </p>
293
+ <p v-if="metadata.length" class="warn">
294
+ <strong>Metadata is public.</strong> These {{ metadata.length }}
295
+ {{ metadata.length === 1 ? 'label is' : 'labels are' }} stored on-chain in the clear,
296
+ unlike the evidence itself. Put identifiers there, not secrets.
297
+ </p>
298
+
299
+ <div class="row" style="margin-top: 16px">
300
+ <button class="primary" :disabled="!canBuild || building" data-testid="build" @click="build">
301
+ {{ building ? 'Reading ledger & signing…' : 'Build & sign' }}
302
+ </button>
303
+ </div>
304
+
305
+ <div v-if="buildError" class="warn" data-testid="build-error">
306
+ <template v-if="buildError.code === 'account-not-found'">
307
+ <strong>This account has never received coins.</strong> A Geeq account only appears
308
+ in the ledger once it has been funded, so a freshly generated phrase will not be
309
+ there yet.
310
+ <div v-if="keypair" class="row" style="margin-top: 8px">
311
+ <code class="v" style="flex: 1 1 auto">{{ toBase64(keypair.accountId) }}</code>
312
+ <CopyButton :value="toBase64(keypair.accountId)" />
313
+ </div>
314
+ </template>
315
+ <template v-else-if="buildError.code === 'nonce-collision'">
316
+ <strong>One transaction per account per block.</strong> Two sharing a nonce are
317
+ <em>both</em> rejected, not just the second — so the SDK refuses rather than
318
+ building one that would destroy the first. Submit the one you have and build again
319
+ once the block advances.
320
+ </template>
321
+ <template v-else>{{ buildError.message }}</template>
322
+ </div>
323
+
324
+ <template v-if="tx && chainState && chain">
325
+ <ChainState :chain-state="chainState" nonce-of="this account" />
326
+
327
+ <TxFee :chain="chain" :fee="tx.fee" payer="this account" />
328
+
329
+ <div class="field">
330
+ <div class="k">Transaction hash · hex · how the chain identifies it</div>
331
+ <div class="v">{{ toHex(tx.transactionHash) }}</div>
332
+ </div>
333
+
334
+ <div class="field">
335
+ <div class="k">Signed transaction · base64 · {{ tx.transaction.length }} chars</div>
336
+ <div class="v wrap" data-testid="tx-base64">{{ tx.transaction }}</div>
337
+ <div class="row" style="margin-top: 7px"><CopyButton :value="tx.transaction" /></div>
338
+ </div>
339
+
340
+ <div class="field">
341
+ <div class="k">Payload · base64 · {{ tx.payload.length }} chars · sent with the transaction</div>
342
+ <div class="v wrap" data-testid="payload-base64">{{ tx.payload }}</div>
343
+ <div class="row" style="margin-top: 7px"><CopyButton :value="tx.payload" /></div>
344
+ </div>
345
+
346
+ <h3 class="sub">Submit</h3>
347
+ <p class="hint">
348
+ It is sent with its payload to <code>{{ tx.nodeUrl ?? chain.endpoint }}</code> — the
349
+ node the chain state came from, and the only one that will take it.
350
+ </p>
351
+ <div class="row">
352
+ <button
353
+ class="primary"
354
+ :disabled="submitting || expired || connection.state === 'connecting'"
355
+ data-testid="submit"
356
+ @click="submit"
357
+ >
358
+ {{ submitting ? 'Submitting…' : 'Submit to node' }}
359
+ </button>
360
+ </div>
361
+ <p class="hint">
362
+ The node's reply only means it took the transaction in. Whether it lands — the nonce
363
+ right, the account able to pay — is decided when the next block is built, and that
364
+ answer comes back over the node socket, shown below.
365
+ </p>
366
+ <TxDeadline :valid-until-block="tx.validUntilBlock" :expired="expired" />
367
+ <SocketWarning :connection="connection" />
368
+
369
+ <div v-if="submitError" class="warn">{{ submitError }}</div>
370
+ <div v-if="submitResult" class="field">
371
+ <div class="k">Node response</div>
372
+ <div class="v wrap">{{ submitResult }}</div>
373
+ </div>
374
+ <p v-if="submitResult && notTracked" class="warn" data-testid="tx-untracked">
375
+ <strong>Sent, but no confirmation will follow.</strong> {{ notTracked }}
376
+ </p>
377
+ </template>
378
+
379
+ <TxStatus v-if="tracked" :tracked="tracked" />
380
+
381
+ </section>
382
+ </template>
383
+
384
+ <style scoped>
385
+ .ctl { display: inline-flex; align-items: center; gap: 8px; }
386
+ .ctl.grow { flex: 1 1 auto; }
387
+ .ctl.grow input { width: 100%; }
388
+ .sub {
389
+ font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em;
390
+ color: var(--muted); font-weight: 600; margin: 22px 0 8px;
391
+ }
392
+ /* Base64 blobs are long and unbroken; let them wrap rather than scroll the page. */
393
+ .v.wrap { word-break: break-all; white-space: normal; }
394
+
395
+ .over { color: #b91c1c; font-weight: 600; }
396
+ </style>
@@ -0,0 +1,119 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { getExplorerTransaction } from '@geeqdev/geeq-sdk'
4
+ import type { GeeqExplorerTransaction } from '@geeqdev/geeq-sdk'
5
+
6
+ const explorerUrl = ref('')
7
+ const refHash = ref('')
8
+
9
+ const loading = ref(false)
10
+ const error = ref('')
11
+ const notFound = ref(false)
12
+ const found = ref<GeeqExplorerTransaction | null>(null)
13
+
14
+ async function lookUp() {
15
+ loading.value = true
16
+ error.value = ''
17
+ notFound.value = false
18
+ found.value = null
19
+ try {
20
+ // Given in base64; the SDK asks the explorer in hex, the form it stores.
21
+ found.value = await getExplorerTransaction({
22
+ explorerUrl: explorerUrl.value.trim(),
23
+ refHash: refHash.value.trim(),
24
+ })
25
+ notFound.value = found.value === null
26
+ } catch (e) {
27
+ error.value = (e as Error).message
28
+ } finally {
29
+ loading.value = false
30
+ }
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <section class="card">
36
+ <h2><span class="step">9</span>Block explorer</h2>
37
+
38
+ <p class="hint" style="margin-top: 0">
39
+ Look a transaction up in a block explorer by its id: the base64 one shown under a
40
+ verdict in steps 4 to 7. The explorer only has a transaction once a block has decided
41
+ it.
42
+ </p>
43
+
44
+ <div class="row">
45
+ <span class="ctl grow">
46
+ <label for="explorer-url">Explorer URL</label>
47
+ <input
48
+ id="explorer-url"
49
+ v-model="explorerUrl"
50
+ type="text"
51
+ placeholder="https://blkexp.geeq-test.io"
52
+ data-testid="explorer-url"
53
+ />
54
+ </span>
55
+ </div>
56
+ <div class="row">
57
+ <span class="ctl grow">
58
+ <label for="explorer-refhash">Transaction id</label>
59
+ <input
60
+ id="explorer-refhash"
61
+ v-model="refHash"
62
+ type="text"
63
+ placeholder="base64, 44 characters"
64
+ spellcheck="false"
65
+ data-testid="explorer-refhash"
66
+ />
67
+ </span>
68
+ <button
69
+ class="primary"
70
+ :disabled="!explorerUrl.trim() || !refHash.trim() || loading"
71
+ data-testid="explorer-lookup"
72
+ @click="lookUp"
73
+ >
74
+ {{ loading ? 'Looking up…' : 'Look up' }}
75
+ </button>
76
+ </div>
77
+
78
+ <div v-if="error" class="warn" data-testid="explorer-error">{{ error }}</div>
79
+ <p v-if="notFound" class="warn" data-testid="explorer-not-found">
80
+ The explorer has no transaction with that id — not indexed yet, or never put in a
81
+ block.
82
+ </p>
83
+
84
+ <template v-if="found">
85
+ <table class="grid" data-testid="explorer-result">
86
+ <tbody>
87
+ <tr><th>Status</th><td>{{ found.status }}</td></tr>
88
+ <tr><th>Type</th><td>{{ found.type }}</td></tr>
89
+ <tr><th>Block</th><td>{{ found.block }} · chain {{ found.chainNumber }} {{ found.chainName }}</td></tr>
90
+ <tr><th>From</th><td class="wrap">{{ found.from }}</td></tr>
91
+ <tr v-if="found.to"><th>To</th><td class="wrap">{{ found.to }}</td></tr>
92
+ <tr v-for="(value, index) in found.values" :key="index">
93
+ <th>Moved</th>
94
+ <td>{{ value.value }} {{ value.name ?? value.type }}</td>
95
+ </tr>
96
+ <tr><th>Created</th><td>{{ found.createdAt }}</td></tr>
97
+ </tbody>
98
+ </table>
99
+ <details class="raw">
100
+ <summary>Everything the explorer returned</summary>
101
+ <pre>{{ JSON.stringify(found, null, 2) }}</pre>
102
+ </details>
103
+ </template>
104
+ </section>
105
+ </template>
106
+
107
+ <style scoped>
108
+ .ctl { display: inline-flex; align-items: center; gap: 8px; }
109
+ .ctl.grow { flex: 1 1 auto; }
110
+ .ctl.grow input { width: 100%; }
111
+ .ctl label { white-space: nowrap; }
112
+ .grid { width: 100%; border-collapse: collapse; font-size: 14px; margin-top: 14px; }
113
+ .grid th { text-align: left; font-weight: 600; padding: 5px 12px 5px 0; white-space: nowrap; vertical-align: top; }
114
+ .grid td { padding: 5px 0; vertical-align: top; }
115
+ .wrap { word-break: break-all; }
116
+ .raw { margin-top: 12px; }
117
+ .raw summary { cursor: pointer; color: var(--muted); font-size: 13px; }
118
+ .raw pre { font-size: 12px; overflow-x: auto; }
119
+ </style>