@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.
- package/.idea/geeq_sdk.iml +12 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/.nvmrc +1 -0
- package/CONTRIBUTING.md +20 -0
- package/example/.env.example +4 -0
- package/example/README.md +304 -0
- package/example/USAGE.md +74 -0
- package/example/index.html +12 -0
- package/example/package-lock.json +1557 -0
- package/example/package.json +25 -0
- package/example/scripts/browser-check.mjs +1421 -0
- package/example/scripts/match-derivation.mjs +65 -0
- package/example/src/App.vue +138 -0
- package/example/src/components/AuthorityPanel.vue +277 -0
- package/example/src/components/ChainPanel.vue +267 -0
- package/example/src/components/ChainState.vue +59 -0
- package/example/src/components/CopyButton.vue +25 -0
- package/example/src/components/CreateAssetPanel.vue +313 -0
- package/example/src/components/DigitalAssetsPanel.vue +68 -0
- package/example/src/components/EvidencePanel.vue +396 -0
- package/example/src/components/ExplorerPanel.vue +119 -0
- package/example/src/components/KeypairPanel.vue +182 -0
- package/example/src/components/PhrasePanel.vue +105 -0
- package/example/src/components/SocketWarning.vue +18 -0
- package/example/src/components/StepGate.vue +24 -0
- package/example/src/components/TransferPanel.vue +416 -0
- package/example/src/components/TxDeadline.vue +19 -0
- package/example/src/components/TxFee.vue +31 -0
- package/example/src/components/TxStatus.vue +105 -0
- package/example/src/composables/useSocketConnection.ts +80 -0
- package/example/src/env.d.ts +12 -0
- package/example/src/main.ts +5 -0
- package/example/src/pennies.ts +7 -0
- package/example/src/style.css +149 -0
- package/example/tsconfig.json +19 -0
- package/example/vite.config.ts +26 -0
- package/jest.config.js +18 -0
- package/package.json +1 -5
- package/scripts/copy-wasm.mjs +21 -0
- package/scripts/smoke.mjs +382 -0
- package/src/address/record-type.ts +77 -0
- package/src/address/wallet-address.ts +105 -0
- package/src/amount.ts +143 -0
- package/src/assert.ts +30 -0
- package/src/capnp/README.md +15 -0
- package/src/capnp/adminNetTx.capnp.ts +66 -0
- package/src/capnp/appLayBlock.capnp.ts +26 -0
- package/src/capnp/appLayLedger.capnp.ts +35 -0
- package/src/capnp/appLayRec.capnp.ts +159 -0
- package/src/capnp/chainStateView.capnp.ts +49 -0
- package/src/capnp/coinAcctRec.capnp.ts +193 -0
- package/src/capnp/genesisBlock.capnp.ts +56 -0
- package/src/capnp/hubContentStmt.capnp.ts +59 -0
- package/src/capnp/misc.capnp.ts +19 -0
- package/src/capnp/netActorRec.capnp.ts +64 -0
- package/src/capnp/nodeContentStmt.capnp.ts +62 -0
- package/src/capnp/nodePayloadList.capnp.ts +28 -0
- package/src/capnp/nodeTxList.capnp.ts +37 -0
- package/src/capnp/paramAcctRec.capnp.ts +669 -0
- package/src/capnp/payload.capnp.ts +261 -0
- package/src/capnp/proxyAcctRec.capnp.ts +259 -0
- package/src/capnp/relayContentStmt.capnp.ts +85 -0
- package/src/capnp/sysAcctRec.capnp.ts +47 -0
- package/src/capnp/userAppTx.capnp.ts +269 -0
- package/src/capnp/userValTx.capnp.ts +1999 -0
- package/src/capnp/valLayBlock.capnp.ts +93 -0
- package/src/capnp/valLayLedger.capnp.ts +80 -0
- package/src/chain/geeq-chain.ts +1094 -0
- package/src/chain/geeq-node.ts +105 -0
- package/src/chain/nonce-guard.ts +73 -0
- package/src/chain/select.ts +149 -0
- package/src/crypto/account-id.ts +83 -0
- package/src/crypto/keypair.ts +221 -0
- package/src/crypto/mnemonic.ts +69 -0
- package/src/encoding/base64.ts +58 -0
- package/src/encoding/hex.ts +42 -0
- package/src/errors.ts +122 -0
- package/src/index.ts +195 -0
- package/src/ledger/coin-accounts.ts +73 -0
- package/src/ledger/decode.ts +147 -0
- package/src/ledger/geeq-ledger.ts +184 -0
- package/src/ledger/net-actors.ts +129 -0
- package/src/ledger/nft-records.ts +49 -0
- package/src/ledger/params.ts +82 -0
- package/src/ledger/proxy-accounts.ts +51 -0
- package/src/net/discovery.ts +231 -0
- package/src/net/explorer.ts +123 -0
- package/src/net/read-ledger.ts +58 -0
- package/src/net/routes.ts +79 -0
- package/src/net/transport.ts +221 -0
- package/src/net/websocket.ts +94 -0
- package/src/runtime/bindings.ts +38 -0
- package/src/runtime/init.ts +49 -0
- package/src/runtime/wasm-loader.browser.ts +22 -0
- package/src/runtime/wasm-loader.node.ts +16 -0
- package/src/socket/geeq-socket.ts +827 -0
- package/src/socket/tracked-tx.ts +319 -0
- package/src/socket/tx-status.ts +131 -0
- package/src/tx/attestation.ts +222 -0
- package/src/tx/content.ts +82 -0
- package/src/tx/encode.ts +107 -0
- package/src/tx/expiry.ts +11 -0
- package/src/tx/fee.ts +31 -0
- package/src/tx/metadata.ts +100 -0
- package/src/tx/nft.ts +108 -0
- package/src/tx/proxy.ts +156 -0
- package/src/tx/transfer.ts +162 -0
- package/src/types.ts +503 -0
- package/src/wasm/README.md +22 -0
- package/src/wasm/browser/geeq_core.d.ts +1224 -0
- package/src/wasm/browser/geeq_core.js +6014 -0
- package/src/wasm/browser/geeq_core_bg.wasm +0 -0
- package/src/wasm/browser/geeq_core_bg.wasm.d.ts +447 -0
- package/src/wasm/nodejs/geeq_core.d.ts +753 -0
- package/src/wasm/nodejs/geeq_core.js +6016 -0
- package/src/wasm/nodejs/geeq_core_bg.wasm +0 -0
- package/src/wasm/nodejs/geeq_core_bg.wasm.d.ts +447 -0
- package/src/wasm/nodejs/package.json +3 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +20 -0
- 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>
|