@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.
- 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,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>
|