@geeqdev/geeq-sdk 0.0.1 → 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,267 @@
1
+ <script setup lang="ts">
2
+ import { computed, markRaw, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
3
+ import { GeeqChain, GeeqError } from '@geeqdev/geeq-sdk'
4
+ import type { SocketConnection } from '../composables/useSocketConnection'
5
+
6
+ // The socket itself is opened by App whenever the chain changes; this panel only shows it.
7
+ const props = defineProps<{
8
+ connection: SocketConnection
9
+ /** Set in step 2, where the address is derived for it. Read here, never changed. */
10
+ chainNumber: number
11
+ }>()
12
+ /** Only for a harness that serves the socket somewhere other than the node's own /ws/. */
13
+ const socketUrl = defineModel<string>('socketUrl', { default: '' })
14
+ const emit = defineEmits<{
15
+ (e: 'chain', chain: GeeqChain | null): void
16
+ }>()
17
+
18
+ const simulatorUrl = ref('')
19
+ /**
20
+ * Deployment configuration, not node selection.
21
+ *
22
+ * Nodes answer on 2082 behind TLS; this exists so the demo can be pointed at a local
23
+ * harness that is not behind one.
24
+ */
25
+ const nodePort = ref<number | null>(null)
26
+
27
+ const chain = shallowRef<GeeqChain | null>(null)
28
+ const connecting = ref(false)
29
+ const error = ref<{ code: string; message: string } | null>(null)
30
+ /** The chain the last node was found for, once step 2 has moved off it. */
31
+ const leftChain = ref<number | null>(null)
32
+
33
+ onBeforeUnmount(() => {
34
+ chain.value?.dispose()
35
+ })
36
+
37
+ /** Every SDK failure carries a stable code; branch on that, never on the message. */
38
+ function asGeeq(e: unknown): { code: string; message: string } {
39
+ if (e instanceof GeeqError) return { code: e.code, message: e.message }
40
+ const shaped = e as { name?: string; code?: string; message?: string }
41
+ if (shaped?.name === 'GeeqError' && typeof shaped.code === 'string') {
42
+ return { code: shaped.code, message: shaped.message ?? '' }
43
+ }
44
+ return { code: 'unknown', message: (e as Error)?.message ?? String(e) }
45
+ }
46
+
47
+ const candidates = computed(() => chain.value?.candidates ?? [])
48
+
49
+ // A node serves one chain. When step 2 moves to another, this node is no use to the
50
+ // steps after it, so let it go rather than leave them building for the wrong chain.
51
+ watch(
52
+ () => props.chainNumber,
53
+ (next) => {
54
+ if (chain.value === null || chain.value.chainNumber === next) return
55
+ leftChain.value = chain.value.chainNumber ?? null
56
+ chain.value.dispose()
57
+ chain.value = null
58
+ emit('chain', null)
59
+ },
60
+ )
61
+
62
+ const socketDot = computed(
63
+ () =>
64
+ ({
65
+ connecting: 'loading',
66
+ reconnecting: 'loading',
67
+ open: 'ready',
68
+ failed: 'error',
69
+ closed: 'error',
70
+ none: '',
71
+ })[props.connection.state],
72
+ )
73
+
74
+ /**
75
+ * Ask the simulator which nodes exist and take an active one.
76
+ *
77
+ * No ledger is read here — only the node's address is settled. Its number, the block, the
78
+ * nonce and the chain's limits all live in the ledger, and that is read when a
79
+ * transaction is built.
80
+ */
81
+ async function connect() {
82
+ if (simulatorUrl.value.trim() === '') return
83
+
84
+ connecting.value = true
85
+ error.value = null
86
+ leftChain.value = null
87
+ try {
88
+ chain.value?.dispose()
89
+ emit('chain', null)
90
+ const requested = props.chainNumber
91
+ const found = await GeeqChain.connect({
92
+ discovery: simulatorUrl.value.trim(),
93
+ // A simulator can carry several chains, so it has to be told which: the one
94
+ // step 2 derives the address for.
95
+ chainNumber: requested,
96
+ // A cleared `v-model.number` field holds '', not null, and means the default port.
97
+ ...(typeof nodePort.value === 'number' ? { nodePort: nodePort.value } : {}),
98
+ // The chain counts only pennies; how many make a Geeq is this app's setting.
99
+ penniesPerGeeq: BigInt(import.meta.env.VITE_PENNIES_PER_GEEQ),
100
+ })
101
+ // Step 2 moved to another chain while the simulator was answering.
102
+ if (props.chainNumber !== requested) {
103
+ found.dispose()
104
+ leftChain.value = requested
105
+ return
106
+ }
107
+ chain.value = markRaw(found)
108
+ // App opens this chain's socket on the strength of it.
109
+ emit('chain', chain.value)
110
+ } catch (e) {
111
+ chain.value = null
112
+ emit('chain', null)
113
+ error.value = asGeeq(e)
114
+ } finally {
115
+ connecting.value = false
116
+ }
117
+ }
118
+ </script>
119
+
120
+ <template>
121
+ <section class="card">
122
+ <h2><span class="step">3</span>Chain</h2>
123
+
124
+ <p class="hint" style="margin-top: 0">
125
+ You name the <strong>simulator</strong>, not a node; the chain number is the one step 2
126
+ derived your address for. The SDK asks the simulator which nodes serve that chain, and
127
+ takes an active one. Nothing is read from the chain itself yet: the block, the node's number
128
+ and your nonce all live in its ledger, and that goes stale within a few blocks — so
129
+ it is read when a transaction is actually signed, not now. The node's socket is
130
+ opened straight away, though: that is where the verdicts on what you send arrive.
131
+ </p>
132
+
133
+ <div class="row">
134
+ <span class="ctl grow">
135
+ <label for="simulator">Simulator URL</label>
136
+ <input
137
+ id="simulator"
138
+ v-model="simulatorUrl"
139
+ type="text"
140
+ placeholder="https://simulator.geeq-test.io"
141
+ data-testid="simulator-url"
142
+ />
143
+ </span>
144
+ <span class="ctl">
145
+ <span class="label">Chain number</span>
146
+ <span class="fixed" data-testid="chain-number">{{ chainNumber }}</span>
147
+ </span>
148
+ <button
149
+ class="primary"
150
+ :disabled="!simulatorUrl.trim() || connecting"
151
+ data-testid="connect"
152
+ @click="connect"
153
+ >
154
+ {{ connecting ? 'Asking…' : 'Find a node' }}
155
+ </button>
156
+ </div>
157
+ <p class="hint">
158
+ Set in step 2: your address encodes it. To use another chain, change it there and
159
+ find a node again.
160
+ </p>
161
+
162
+ <details class="advanced">
163
+ <summary data-testid="dev-options">Local harness options</summary>
164
+ <p class="hint">
165
+ Nodes answer on <strong>2082</strong> behind TLS, and that is what the SDK uses.
166
+ Override it only when pointing the demo at a local stub that is not behind one.
167
+ </p>
168
+ <span class="ctl">
169
+ <label for="node-port">Node port</label>
170
+ <input
171
+ id="node-port"
172
+ v-model.number="nodePort"
173
+ type="number"
174
+ min="1"
175
+ placeholder="2082"
176
+ data-testid="node-port"
177
+ />
178
+ </span>
179
+ <span class="ctl grow" style="margin-top: 10px">
180
+ <label for="socket-url">Socket URL</label>
181
+ <input
182
+ id="socket-url"
183
+ v-model="socketUrl"
184
+ type="text"
185
+ placeholder="derived: the node URL, ws scheme, /ws/"
186
+ data-testid="socket-url"
187
+ />
188
+ </span>
189
+ </details>
190
+
191
+ <div v-if="error" class="warn" data-testid="connect-error">
192
+ <template v-if="error.code === 'network-unreachable'">
193
+ <strong>Could not reach that simulator.</strong> A browser cannot tell these apart:
194
+ <ul style="margin: 6px 0 0 18px">
195
+ <li>it sends no CORS header for this origin;</li>
196
+ <li>its TLS certificate is not trusted;</li>
197
+ <li>this page is https and the URL is http, which browsers block;</li>
198
+ <li>it is down, or the URL is wrong.</li>
199
+ </ul>
200
+ </template>
201
+ <template v-else>{{ error.message }}</template>
202
+ </div>
203
+
204
+ <p v-if="leftChain !== null" class="warn" data-testid="chain-changed">
205
+ Step 2 moved from chain <strong>{{ leftChain }}</strong> to chain
206
+ <strong>{{ chainNumber }}</strong>, so the node found for chain {{ leftChain }} was let
207
+ go. Find a node again.
208
+ </p>
209
+
210
+ <template v-if="chain">
211
+ <div class="field" data-testid="selected-node">
212
+ <div class="k">Selected node · chosen by the SDK from the active ones</div>
213
+ <div class="v">
214
+ {{ chain.endpoint }}
215
+ <span class="hint" style="margin-left: 8px">
216
+ chain {{ chain.chainNumber ?? '—' }}
217
+ <template v-if="chain.chainName">({{ chain.chainName }})</template> ·
218
+ {{ candidates.length }} listed,
219
+ {{ candidates.filter((n) => n.kind === 'active').length }} active
220
+ </span>
221
+ </div>
222
+ </div>
223
+
224
+ <div class="field" data-testid="socket" :data-state="connection.state">
225
+ <div class="k">Node socket · where transaction verdicts arrive</div>
226
+ <div class="v">
227
+ <span class="dot" :class="socketDot" style="display: inline-block; margin-right: 7px" />
228
+ <span data-testid="socket-state">{{ connection.state }}</span>
229
+ <span v-if="connection.socket" class="hint" style="margin-left: 8px">
230
+ {{ connection.socket.url }}
231
+ </span>
232
+ <button
233
+ v-if="connection.state === 'failed'"
234
+ class="small"
235
+ style="margin-left: 8px"
236
+ data-testid="socket-retry"
237
+ @click="connection.retry()"
238
+ >
239
+ Retry
240
+ </button>
241
+ </div>
242
+ <p v-if="connection.error" class="warn" data-testid="socket-error">
243
+ {{ connection.error }} Transactions can still be built and submitted, but their
244
+ verdicts will not be shown.
245
+ </p>
246
+ <ul v-if="connection.lastEvents.length" class="recent" data-testid="socket-events">
247
+ <li v-for="(event, index) in connection.lastEvents" :key="index">
248
+ <code>{{ event }}</code>
249
+ </li>
250
+ </ul>
251
+ </div>
252
+ </template>
253
+ </section>
254
+ </template>
255
+
256
+ <style scoped>
257
+ .ctl { display: inline-flex; align-items: center; gap: 8px; }
258
+ .ctl input { width: 140px; }
259
+ .ctl.grow { flex: 1 1 auto; }
260
+ .ctl.grow input { width: 100%; }
261
+ .label { font-size: 13px; color: var(--muted); }
262
+ /* A value shown, not asked for: no field border, so it does not read as editable. */
263
+ .fixed { font-family: var(--mono); font-size: 14px; font-weight: 600; padding: 0 4px; }
264
+ .advanced { margin-top: 14px; }
265
+ .advanced summary { cursor: pointer; color: var(--muted); font-size: 13px; }
266
+ .recent { margin: 8px 0 0 18px; padding: 0; font-size: 13px; }
267
+ </style>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import type { ChainStateInput } from '@geeqdev/geeq-sdk'
3
+
4
+ /**
5
+ * What a built transaction is pinned to. `nonceOf` says whose nonce it spends: creating a
6
+ * digital asset spends its authority account's, everything else the signing account's.
7
+ */
8
+ defineProps<{ chainState: ChainStateInput; nonceOf: string }>()
9
+ </script>
10
+
11
+ <template>
12
+ <h3 class="sub">Chain state · read from the ledger while signing</h3>
13
+ <table class="grid" data-testid="chain-state">
14
+ <tbody>
15
+ <tr>
16
+ <th>Chain</th>
17
+ <td>{{ chainState.chainNumber }}</td>
18
+ <td class="src">the chain the node serves</td>
19
+ </tr>
20
+ <tr>
21
+ <th>Ledger block</th>
22
+ <td>{{ chainState.ledgerBlockNumber }}</td>
23
+ <td class="src">the chain's latest block when it was signed</td>
24
+ </tr>
25
+ <tr>
26
+ <th>Target block</th>
27
+ <td>{{ chainState.blockNumber }}</td>
28
+ <td class="src">the next block — the one it asks to go into</td>
29
+ </tr>
30
+ <tr>
31
+ <th>Node number</th>
32
+ <td>{{ chainState.nodeNumber }}</td>
33
+ <td class="src">
34
+ the node it was built for, <code>{{ chainState.nodeUrl }}</code> — no other takes it
35
+ </td>
36
+ </tr>
37
+ <tr>
38
+ <th>Nonce</th>
39
+ <td>{{ chainState.nonce }}</td>
40
+ <td class="src">the number {{ nonceOf }}'s next transaction must carry</td>
41
+ </tr>
42
+ </tbody>
43
+ </table>
44
+ </template>
45
+
46
+ <style scoped>
47
+ .sub {
48
+ font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em;
49
+ color: var(--muted); font-weight: 600; margin: 22px 0 8px;
50
+ }
51
+ .grid { width: 100%; border-collapse: collapse; font-size: 14px; }
52
+ .grid th {
53
+ text-align: left; font-weight: 600; padding: 5px 12px 5px 0;
54
+ white-space: nowrap; vertical-align: top;
55
+ }
56
+ .grid td { padding: 5px 12px 5px 0; vertical-align: top; }
57
+ .grid td:nth-child(2) { font-variant-numeric: tabular-nums; font-weight: 600; }
58
+ .grid .src { color: var(--muted); font-size: 13px; width: 100%; }
59
+ </style>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+
4
+ const props = defineProps<{ value: string; label?: string }>()
5
+ const copied = ref(false)
6
+ let timer: ReturnType<typeof setTimeout> | undefined
7
+
8
+ async function copy() {
9
+ try {
10
+ await navigator.clipboard.writeText(props.value)
11
+ } catch {
12
+ // Clipboard is unavailable outside a secure context; the value stays selectable.
13
+ return
14
+ }
15
+ copied.value = true
16
+ clearTimeout(timer)
17
+ timer = setTimeout(() => (copied.value = false), 1400)
18
+ }
19
+ </script>
20
+
21
+ <template>
22
+ <button class="small" type="button" @click="copy">
23
+ {{ copied ? 'Copied' : (label ?? 'Copy') }}
24
+ </button>
25
+ </template>
@@ -0,0 +1,313 @@
1
+ <script setup lang="ts">
2
+ import { markRaw, ref, shallowRef, watch } from 'vue'
3
+ import { attestationSubmitBody, submitRoute, toBase64 } from '@geeqdev/geeq-sdk'
4
+ import type {
5
+ GeeqChain,
6
+ GeeqKeypair,
7
+ GeeqNftMintProxies,
8
+ GeeqTrackedTx,
9
+ NftMintTx,
10
+ } from '@geeqdev/geeq-sdk'
11
+ import type { SocketConnection } from '../composables/useSocketConnection'
12
+ import { penniesLabel } from '../pennies'
13
+ import ChainState from './ChainState.vue'
14
+ import CopyButton from './CopyButton.vue'
15
+ import SocketWarning from './SocketWarning.vue'
16
+ import TxDeadline from './TxDeadline.vue'
17
+ import TxFee from './TxFee.vue'
18
+ import TxStatus from './TxStatus.vue'
19
+
20
+ const props = defineProps<{
21
+ keypair: GeeqKeypair | null
22
+ chain: GeeqChain | null
23
+ connection: SocketConnection
24
+ }>()
25
+
26
+ /** This account's authority accounts as the ledger last listed them, or null before the first look. */
27
+ const found = shallowRef<GeeqNftMintProxies | null>(null)
28
+ const finding = ref(false)
29
+ const findError = ref('')
30
+ /** The index of the authority account to create the digital asset through. */
31
+ const index = ref<number | null>(null)
32
+
33
+ // The SDK takes a File directly and hashes it; only the hash goes on chain.
34
+ const file = ref<File | null>(null)
35
+ const metadataInput = ref('')
36
+
37
+ const tx = ref<NftMintTx | null>(null)
38
+ const building = ref(false)
39
+ const buildError = ref('')
40
+ const submitting = ref(false)
41
+ const submitError = ref('')
42
+ const submitted = ref(false)
43
+ /** Set when the check before submitting found the built transaction too old. */
44
+ const expired = ref(false)
45
+ /** The last submission, followed to its verdict. */
46
+ const tracked = shallowRef<GeeqTrackedTx | null>(null)
47
+ /** Why the last submission is not being followed, when it is not. */
48
+ const notTracked = ref('')
49
+
50
+ /**
51
+ * Drop the built transaction. One that was never sent gives its nonce back, so building
52
+ * again in the same block is not refused.
53
+ */
54
+ function dropTx() {
55
+ if (tx.value !== null && !submitted.value) props.chain?.discard(tx.value)
56
+ tx.value = null
57
+ submitted.value = false
58
+ expired.value = false
59
+ }
60
+
61
+ // A built transaction is pinned to a block and a nonce, so any change invalidates it.
62
+ watch([index, file, metadataInput, () => props.keypair, () => props.chain], () => {
63
+ dropTx()
64
+ submitError.value = ''
65
+ })
66
+ watch([() => props.keypair, () => props.chain], () => {
67
+ found.value = null
68
+ index.value = null
69
+ })
70
+
71
+ async function findAuthorities() {
72
+ if (props.keypair === null || props.chain === null) return
73
+
74
+ finding.value = true
75
+ findError.value = ''
76
+ try {
77
+ // A fresh read: a new authority account, and a nonce that creating a digital asset
78
+ // moved, show only once a block has confirmed them.
79
+ found.value = await props.chain.findNftMintProxies({ admin: props.keypair, maxAgeMs: 0 })
80
+ // Keep the chosen authority account across a refresh; otherwise take the first.
81
+ if (!found.value.proxies.some((authority) => authority.index === index.value)) {
82
+ index.value = found.value.proxies[0]?.index ?? null
83
+ }
84
+ } catch (error) {
85
+ findError.value = (error as Error).message
86
+ } finally {
87
+ finding.value = false
88
+ }
89
+ }
90
+
91
+ function onFile(event: Event) {
92
+ file.value = (event.target as HTMLInputElement).files?.[0] ?? null
93
+ }
94
+
95
+ async function build() {
96
+ if (props.keypair === null || props.chain === null || index.value === null || file.value === null) return
97
+
98
+ building.value = true
99
+ buildError.value = ''
100
+ dropTx()
101
+ try {
102
+ // Hashes the file, finds the authority account in the ledger by this account's key and
103
+ // the index, checks it can pay, then signs.
104
+ tx.value = await props.chain.mintNft({
105
+ admin: props.keypair,
106
+ index: index.value,
107
+ content: file.value,
108
+ metadata: metadataInput.value
109
+ .split(',')
110
+ .map((label) => label.trim())
111
+ .filter((label) => label !== ''),
112
+ })
113
+ } catch (error) {
114
+ buildError.value = (error as Error).message
115
+ } finally {
116
+ building.value = false
117
+ }
118
+ }
119
+
120
+ async function submit() {
121
+ const built = tx.value
122
+ const target = built?.nodeUrl ?? props.chain?.endpoint
123
+ if (built === null || target === undefined || props.chain === null) return
124
+
125
+ submitting.value = true
126
+ submitError.value = ''
127
+ submitted.value = false
128
+ tracked.value = null
129
+ notTracked.value = ''
130
+ try {
131
+ // It may have waited, and a node refuses one the chain has moved too far past.
132
+ if (await props.chain.isExpired(built)) {
133
+ expired.value = true
134
+ return
135
+ }
136
+
137
+ // Listen before sending, as for evidence.
138
+ const socket = props.connection.socket
139
+ if (socket === null) {
140
+ notTracked.value = 'The node socket is not open.'
141
+ } else {
142
+ try {
143
+ tracked.value = markRaw(await socket.track(built))
144
+ } catch (e) {
145
+ notTracked.value = `Could not subscribe for its verdict: ${(e as Error).message}`
146
+ }
147
+ }
148
+
149
+ // Creating a digital asset is an application-layer transaction, so it posts exactly
150
+ // like evidence: the transaction and its payload, as `uat`.
151
+ const response = await fetch(submitRoute(target), {
152
+ method: 'POST',
153
+ headers: { 'content-type': 'application/json' },
154
+ body: attestationSubmitBody(built.transaction, built.payload),
155
+ })
156
+ if (!response.ok) {
157
+ const body = (await response.text()).slice(0, 400)
158
+ tracked.value?.refused({ status: response.status, message: body })
159
+ submitError.value = `Node replied ${response.status} ${response.statusText}: ${body}`
160
+ return
161
+ }
162
+ submitted.value = true
163
+ } catch (e) {
164
+ submitError.value = `Could not reach the node — ${(e as Error).message}.`
165
+ } finally {
166
+ submitting.value = false
167
+ }
168
+ }
169
+ </script>
170
+
171
+ <template>
172
+ <section class="card">
173
+ <h2><span class="step">7</span>Create a digital asset</h2>
174
+
175
+ <p class="hint" style="margin-top: 0">
176
+ A digital asset is created through one of this account's <strong>authority
177
+ accounts</strong> from step 6. The authority account pays: the fee, and the chain's
178
+ minimum transfer, which goes to this account with the digital asset. This account
179
+ signs, as its admin.
180
+ </p>
181
+
182
+ <div class="row">
183
+ <span class="ctl">
184
+ <label for="asset-authority">Authority account</label>
185
+ <select
186
+ id="asset-authority"
187
+ v-model="index"
188
+ :disabled="!found || found.proxies.length === 0"
189
+ data-testid="asset-authority"
190
+ >
191
+ <option v-if="!found" :value="null">Find your authority accounts first</option>
192
+ <option v-else-if="found.proxies.length === 0" :value="null">
193
+ None yet — delegate authority in step 6
194
+ </option>
195
+ <option
196
+ v-for="authority in found?.proxies"
197
+ :key="authority.address"
198
+ :value="authority.index"
199
+ :disabled="authority.index === undefined"
200
+ >
201
+ Authority account {{ authority.index ?? '(no index)' }} ·
202
+ {{ chain?.formatGeeq(authority.balance) }} geeq {{ penniesLabel(authority.balance) }}
203
+ </option>
204
+ </select>
205
+ </span>
206
+ <button
207
+ :disabled="!keypair || !chain || finding"
208
+ data-testid="find-asset-authorities"
209
+ @click="findAuthorities"
210
+ >
211
+ {{ finding ? 'Reading ledger…' : found ? 'Refresh' : 'Find my authority accounts' }}
212
+ </button>
213
+ </div>
214
+ <div v-if="findError" class="warn">{{ findError }}</div>
215
+
216
+ <div class="row" style="margin-top: 12px">
217
+ <input type="file" data-testid="asset-file" @change="onFile" />
218
+ </div>
219
+ <p class="hint">Only the file's hash goes on chain. The file never leaves this tab.</p>
220
+
221
+ <div class="row">
222
+ <span class="ctl grow">
223
+ <label for="asset-meta">Metadata</label>
224
+ <input
225
+ id="asset-meta"
226
+ v-model="metadataInput"
227
+ type="text"
228
+ placeholder="comma, separated, labels"
229
+ data-testid="asset-metadata"
230
+ />
231
+ </span>
232
+ </div>
233
+ <p class="hint">
234
+ Labels are public, as they are on evidence. The digital asset's id is derived from the
235
+ file's hash and the labels, so the same file with the same labels makes one digital
236
+ asset per authority account, and no more.
237
+ </p>
238
+
239
+ <div class="row" style="margin-top: 16px">
240
+ <button
241
+ class="primary"
242
+ :disabled="!keypair || !chain || index === null || !file || building"
243
+ data-testid="build-asset"
244
+ @click="build"
245
+ >
246
+ {{ building ? 'Reading ledger & signing…' : 'Build & sign' }}
247
+ </button>
248
+ </div>
249
+ <div v-if="buildError" class="warn" data-testid="asset-error">{{ buildError }}</div>
250
+
251
+ <template v-if="tx && chain">
252
+ <div class="field">
253
+ <div class="k">Digital asset id · base64</div>
254
+ <div class="row">
255
+ <code class="v" style="flex: 1 1 auto" data-testid="asset-id">{{ toBase64(tx.nftId) }}</code>
256
+ <CopyButton :value="toBase64(tx.nftId)" />
257
+ </div>
258
+ </div>
259
+
260
+ <ChainState :chain-state="tx.chainState" :nonce-of="`authority account ${index}`" />
261
+ <TxFee :chain="chain" :fee="tx.fee" :amount="tx.amount" :payer="`authority account ${index}`" />
262
+
263
+ <div class="field">
264
+ <div class="k">Signed transaction · base64 · {{ tx.transaction.length }} chars</div>
265
+ <div class="v wrap" data-testid="asset-base64">{{ tx.transaction }}</div>
266
+ </div>
267
+
268
+ <div class="field">
269
+ <div class="k">Payload · base64 · {{ tx.payload.length }} chars · sent with the transaction</div>
270
+ <div class="v wrap">{{ tx.payload }}</div>
271
+ </div>
272
+
273
+ <h3 class="sub">Submit</h3>
274
+ <div class="row">
275
+ <button
276
+ class="primary"
277
+ :disabled="submitting || submitted || expired || connection.state === 'connecting'"
278
+ data-testid="submit-asset"
279
+ @click="submit"
280
+ >
281
+ {{ submitting ? 'Submitting…' : submitted ? 'Submitted' : 'Submit to node' }}
282
+ </button>
283
+ </div>
284
+ <p class="hint">
285
+ Sent like evidence, with a payload. It sends
286
+ {{ chain.formatGeeq(tx.amount) }} geeq {{ penniesLabel(tx.amount) }} to this account with
287
+ the digital asset, and spends authority account
288
+ {{ index }}'s own nonce <strong data-testid="authority-nonce">{{ tx.chainState.nonce }}</strong>,
289
+ not this account's.
290
+ </p>
291
+ <TxDeadline :valid-until-block="tx.validUntilBlock" :expired="expired" />
292
+ <SocketWarning :connection="connection" />
293
+
294
+ <div v-if="submitError" class="warn">{{ submitError }}</div>
295
+ <p v-if="submitted && notTracked" class="warn" data-testid="tx-untracked">
296
+ <strong>Sent, but no confirmation will follow.</strong> {{ notTracked }}
297
+ </p>
298
+ </template>
299
+
300
+ <TxStatus v-if="tracked" :tracked="tracked" />
301
+ </section>
302
+ </template>
303
+
304
+ <style scoped>
305
+ .ctl { display: inline-flex; align-items: center; gap: 8px; }
306
+ .ctl.grow { flex: 1 1 auto; }
307
+ .ctl.grow input { width: 100%; }
308
+ .sub {
309
+ font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em;
310
+ color: var(--muted); font-weight: 600; margin: 22px 0 8px;
311
+ }
312
+ .v.wrap { word-break: break-all; white-space: normal; }
313
+ </style>