@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,416 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
GeeqError,
|
|
5
|
+
coinTransferSubmitBody,
|
|
6
|
+
fromBase64,
|
|
7
|
+
submitRoute,
|
|
8
|
+
toBase64,
|
|
9
|
+
toHex,
|
|
10
|
+
} from '@geeqdev/geeq-sdk'
|
|
11
|
+
import type { CoinTransferTx, GeeqChain, GeeqKeypair, GeeqNft, GeeqTrackedTx } from '@geeqdev/geeq-sdk'
|
|
12
|
+
import type { SocketConnection } from '../composables/useSocketConnection'
|
|
13
|
+
import { penniesLabel } from '../pennies'
|
|
14
|
+
import ChainState from './ChainState.vue'
|
|
15
|
+
import CopyButton from './CopyButton.vue'
|
|
16
|
+
import SocketWarning from './SocketWarning.vue'
|
|
17
|
+
import TxDeadline from './TxDeadline.vue'
|
|
18
|
+
import TxFee from './TxFee.vue'
|
|
19
|
+
import TxStatus from './TxStatus.vue'
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
keypair: GeeqKeypair | null
|
|
23
|
+
chain: GeeqChain | null
|
|
24
|
+
connection: SocketConnection
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const recipient = ref('')
|
|
28
|
+
/** The amount box, as typed: text, so no digits are lost on the way to the SDK. */
|
|
29
|
+
const typedAmount = ref('')
|
|
30
|
+
/** What this transfer sends: coin, or digital assets, which go with the chain's minimum. */
|
|
31
|
+
const sending = ref<'geeq' | 'assets'>('geeq')
|
|
32
|
+
|
|
33
|
+
/** This account's digital assets as the ledger last listed them, or null before the first look. */
|
|
34
|
+
const assets = shallowRef<readonly GeeqNft[] | null>(null)
|
|
35
|
+
const loadingAssets = ref(false)
|
|
36
|
+
const assetsError = ref('')
|
|
37
|
+
/** The base64 ids of the digital assets ticked to send. */
|
|
38
|
+
const chosen = ref<string[]>([])
|
|
39
|
+
|
|
40
|
+
const tx = ref<CoinTransferTx | null>(null)
|
|
41
|
+
const building = ref(false)
|
|
42
|
+
const buildError = ref<{ code: string; message: string } | null>(null)
|
|
43
|
+
const submitting = ref(false)
|
|
44
|
+
const submitError = ref('')
|
|
45
|
+
const submitResult = ref('')
|
|
46
|
+
/** Set when the check before submitting found the built transfer too old. */
|
|
47
|
+
const expired = ref(false)
|
|
48
|
+
/**
|
|
49
|
+
* The last submission, followed to its verdict. Not cleared when the inputs change: the
|
|
50
|
+
* transaction is already out, and its answer is still worth showing.
|
|
51
|
+
*/
|
|
52
|
+
const tracked = shallowRef<GeeqTrackedTx | null>(null)
|
|
53
|
+
/** Why the last submission is not being followed, when it is not. */
|
|
54
|
+
const notTracked = ref('')
|
|
55
|
+
|
|
56
|
+
/** Filled after a build, which is the first moment the ledger has been read. */
|
|
57
|
+
const balance = ref<bigint | null>(null)
|
|
58
|
+
const minimum = ref<bigint | null>(null)
|
|
59
|
+
|
|
60
|
+
/** Every SDK failure carries a stable code; branch on that, never on the message. */
|
|
61
|
+
function asGeeq(error: unknown): { code: string; message: string } {
|
|
62
|
+
if (error instanceof GeeqError) return { code: error.code, message: error.message }
|
|
63
|
+
const e = error as { name?: string; code?: string; message?: string }
|
|
64
|
+
if (e?.name === 'GeeqError' && typeof e.code === 'string') {
|
|
65
|
+
return { code: e.code, message: e.message ?? '' }
|
|
66
|
+
}
|
|
67
|
+
return { code: 'unknown', message: (error as Error)?.message ?? String(error) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What the typed figure comes to in pennies.
|
|
72
|
+
*
|
|
73
|
+
* The chain parses it at its own pennies-per-Geeq, so the demo does not get its own opinion
|
|
74
|
+
* about decimal places — and `null` here means "not a valid amount yet" rather than zero.
|
|
75
|
+
*/
|
|
76
|
+
const pennies = computed<bigint | null>(() => {
|
|
77
|
+
const typed = typedAmount.value.trim()
|
|
78
|
+
if (typed === '' || props.chain === null) return null
|
|
79
|
+
try {
|
|
80
|
+
return props.chain.toPennies({ geeq: typed })
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const amountProblem = computed(() => {
|
|
87
|
+
const typed = typedAmount.value.trim()
|
|
88
|
+
if (typed === '' || props.chain === null) return null
|
|
89
|
+
try {
|
|
90
|
+
const value = props.chain.toPennies({ geeq: typed })
|
|
91
|
+
if (value === 0n) return 'An amount of zero moves nothing.'
|
|
92
|
+
return null
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return asGeeq(error).message
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const canBuild = computed(
|
|
99
|
+
() =>
|
|
100
|
+
props.keypair !== null &&
|
|
101
|
+
props.chain !== null &&
|
|
102
|
+
recipient.value.trim() !== '' &&
|
|
103
|
+
(sending.value === 'geeq' ? pennies.value !== null && pennies.value > 0n : chosen.value.length > 0),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Drop the built transfer. One that was never sent gives its nonce back, so building
|
|
108
|
+
* again in the same block is not refused.
|
|
109
|
+
*/
|
|
110
|
+
function dropTx() {
|
|
111
|
+
if (tx.value !== null && submitResult.value === '') props.chain?.discard(tx.value)
|
|
112
|
+
tx.value = null
|
|
113
|
+
submitResult.value = ''
|
|
114
|
+
expired.value = false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A built transfer is pinned to a block and a nonce, so any change invalidates it.
|
|
118
|
+
watch([recipient, typedAmount, sending, chosen, () => props.keypair, () => props.chain], () => {
|
|
119
|
+
dropTx()
|
|
120
|
+
submitError.value = ''
|
|
121
|
+
})
|
|
122
|
+
watch([() => props.keypair, () => props.chain], () => {
|
|
123
|
+
assets.value = null
|
|
124
|
+
chosen.value = []
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
async function loadAssets() {
|
|
128
|
+
if (props.keypair === null || props.chain === null) return
|
|
129
|
+
|
|
130
|
+
loadingAssets.value = true
|
|
131
|
+
assetsError.value = ''
|
|
132
|
+
try {
|
|
133
|
+
// A fresh read: a digital asset just created or sent shows only once a block has
|
|
134
|
+
// confirmed it.
|
|
135
|
+
assets.value = await props.chain.findNfts({ owner: props.keypair, maxAgeMs: 0 })
|
|
136
|
+
chosen.value = []
|
|
137
|
+
} catch (error) {
|
|
138
|
+
assetsError.value = (error as Error).message
|
|
139
|
+
} finally {
|
|
140
|
+
loadingAssets.value = false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function build() {
|
|
145
|
+
const keypair = props.keypair
|
|
146
|
+
const chain = props.chain
|
|
147
|
+
if (keypair === null || chain === null) return
|
|
148
|
+
|
|
149
|
+
building.value = true
|
|
150
|
+
buildError.value = null
|
|
151
|
+
dropTx()
|
|
152
|
+
try {
|
|
153
|
+
// One call: reads the ledger, checks the recipient's chain, the balance and the
|
|
154
|
+
// chain's own minimum, then signs. Digital assets are named by id; the SDK checks this
|
|
155
|
+
// account holds them and sends them with the chain's minimum.
|
|
156
|
+
tx.value = await chain.createTransfer(
|
|
157
|
+
sending.value === 'geeq'
|
|
158
|
+
? { from: keypair, to: recipient.value.trim(), amount: { geeq: typedAmount.value.trim() } }
|
|
159
|
+
: { from: keypair, to: recipient.value.trim(), nfts: chosen.value.map((id) => fromBase64(id)) },
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const state = await chain.getChainState({ account: keypair })
|
|
163
|
+
balance.value = state.balance
|
|
164
|
+
minimum.value = state.minimumTransfer ?? null
|
|
165
|
+
} catch (error) {
|
|
166
|
+
buildError.value = asGeeq(error)
|
|
167
|
+
} finally {
|
|
168
|
+
building.value = false
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A transfer is a validation-layer transaction: one blob, posted as `uvt`. Evidence is an
|
|
174
|
+
* application-layer one and posts two as `uat`. Same node either way, and tracked
|
|
175
|
+
* the same way.
|
|
176
|
+
*/
|
|
177
|
+
async function submit() {
|
|
178
|
+
const built = tx.value
|
|
179
|
+
const target = built?.nodeUrl ?? props.chain?.endpoint
|
|
180
|
+
if (built === null || target === undefined || props.chain === null) return
|
|
181
|
+
|
|
182
|
+
submitting.value = true
|
|
183
|
+
submitError.value = ''
|
|
184
|
+
submitResult.value = ''
|
|
185
|
+
tracked.value = null
|
|
186
|
+
notTracked.value = ''
|
|
187
|
+
try {
|
|
188
|
+
// It may have waited, and a node refuses one the chain has moved too far past.
|
|
189
|
+
if (await props.chain.isExpired(built)) {
|
|
190
|
+
expired.value = true
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Listen before sending: a verdict that arrives before anyone has subscribed is lost.
|
|
195
|
+
// Without a socket it is still sent, only with no one listening for the answer.
|
|
196
|
+
const socket = props.connection.socket
|
|
197
|
+
if (socket === null) {
|
|
198
|
+
notTracked.value = 'The node socket is not open.'
|
|
199
|
+
} else {
|
|
200
|
+
try {
|
|
201
|
+
tracked.value = markRaw(await socket.track(built))
|
|
202
|
+
} catch (e) {
|
|
203
|
+
notTracked.value = `Could not subscribe for its verdict: ${(e as Error).message}`
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const response = await fetch(submitRoute(target), {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { 'content-type': 'application/json' },
|
|
210
|
+
body: coinTransferSubmitBody(built.transaction),
|
|
211
|
+
})
|
|
212
|
+
const body = await response.text()
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
// A node sends no status for a submission it refused, so the tracker has to be told.
|
|
215
|
+
tracked.value?.refused({ status: response.status, message: body.slice(0, 400) })
|
|
216
|
+
submitError.value = `Node replied ${response.status} ${response.statusText}: ${body.slice(0, 400)}`
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
submitResult.value = body === '' ? `${response.status} (empty body)` : body.slice(0, 1000)
|
|
220
|
+
} catch (e) {
|
|
221
|
+
// The POST may still have arrived, so a tracked transaction stays tracked.
|
|
222
|
+
submitError.value = `Could not reach the node — ${(e as Error).message}.`
|
|
223
|
+
} finally {
|
|
224
|
+
submitting.value = false
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
</script>
|
|
228
|
+
|
|
229
|
+
<template>
|
|
230
|
+
<section class="card">
|
|
231
|
+
<h2><span class="step">5</span>Send coin or digital assets</h2>
|
|
232
|
+
|
|
233
|
+
<p class="hint" style="margin-top: 0">
|
|
234
|
+
Paste an address, then send geeq or digital assets. The SDK reads the ledger, checks
|
|
235
|
+
that the address belongs to this chain and that the amount clears the chain's own
|
|
236
|
+
minimum, then signs, works out the fee from the signed bytes, and checks the account
|
|
237
|
+
holds the amount plus the fee.
|
|
238
|
+
</p>
|
|
239
|
+
|
|
240
|
+
<div class="row">
|
|
241
|
+
<label class="choice">
|
|
242
|
+
<input v-model="sending" type="radio" value="geeq" data-testid="send-geeq" /> Geeq
|
|
243
|
+
</label>
|
|
244
|
+
<label class="choice">
|
|
245
|
+
<input v-model="sending" type="radio" value="assets" data-testid="send-assets" /> Digital assets
|
|
246
|
+
</label>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<template v-if="sending === 'assets'">
|
|
250
|
+
<div class="row">
|
|
251
|
+
<button :disabled="!keypair || !chain || loadingAssets" data-testid="transfer-get-assets" @click="loadAssets">
|
|
252
|
+
{{ loadingAssets ? 'Reading ledger…' : assets ? 'Refresh' : 'Get my digital assets' }}
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
<div v-if="assetsError" class="warn">{{ assetsError }}</div>
|
|
256
|
+
<p v-if="assets && assets.length === 0" class="hint">None yet — create one in step 7.</p>
|
|
257
|
+
<label v-for="asset in assets" :key="toBase64(asset.id)" class="choice asset">
|
|
258
|
+
<input v-model="chosen" type="checkbox" :value="toBase64(asset.id)" data-testid="asset-choice" />
|
|
259
|
+
<span>{{ asset.metadata.join(', ') || 'No labels' }}</span>
|
|
260
|
+
<code>{{ toBase64(asset.id) }}</code>
|
|
261
|
+
</label>
|
|
262
|
+
<p class="hint">
|
|
263
|
+
Tick one or several. They go in one transaction, with the chain's minimum transfer:
|
|
264
|
+
every transfer must carry at least that much coin.
|
|
265
|
+
</p>
|
|
266
|
+
</template>
|
|
267
|
+
|
|
268
|
+
<div class="row" style="margin-top: 12px">
|
|
269
|
+
<span class="ctl grow">
|
|
270
|
+
<label for="recipient">To</label>
|
|
271
|
+
<input
|
|
272
|
+
id="recipient"
|
|
273
|
+
v-model="recipient"
|
|
274
|
+
type="text"
|
|
275
|
+
placeholder="address"
|
|
276
|
+
spellcheck="false"
|
|
277
|
+
data-testid="recipient"
|
|
278
|
+
/>
|
|
279
|
+
</span>
|
|
280
|
+
<span v-if="sending === 'geeq'" class="ctl">
|
|
281
|
+
<label for="amount">Geeq</label>
|
|
282
|
+
<input
|
|
283
|
+
id="amount"
|
|
284
|
+
v-model="typedAmount"
|
|
285
|
+
type="text"
|
|
286
|
+
inputmode="decimal"
|
|
287
|
+
placeholder="1.50"
|
|
288
|
+
data-testid="amount"
|
|
289
|
+
/>
|
|
290
|
+
</span>
|
|
291
|
+
<button class="primary" :disabled="!canBuild || building" data-testid="build-transfer" @click="build">
|
|
292
|
+
{{ building ? 'Reading ledger & signing…' : 'Build & sign' }}
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<template v-if="sending === 'geeq'">
|
|
297
|
+
<p v-if="chain" class="hint">
|
|
298
|
+
Amounts are counted in <strong>pennies</strong>, {{ chain.penniesPerGeeq }} to a Geeq as
|
|
299
|
+
this app is set up.
|
|
300
|
+
<template v-if="pennies !== null"
|
|
301
|
+
>That is <strong data-testid="pennies">{{ pennies }}</strong> pennies.</template
|
|
302
|
+
>
|
|
303
|
+
<template v-else>The SDK refuses more decimal places than that allows, rather than rounding.</template>
|
|
304
|
+
</p>
|
|
305
|
+
<p v-if="amountProblem" class="warn" data-testid="amount-problem">{{ amountProblem }}</p>
|
|
306
|
+
</template>
|
|
307
|
+
|
|
308
|
+
<div v-if="balance !== null && chain" class="field">
|
|
309
|
+
<div class="k">Balance · as at the last ledger read</div>
|
|
310
|
+
<div class="v" data-testid="balance">
|
|
311
|
+
{{ chain.formatGeeq(balance) }} geeq {{ penniesLabel(balance) }}
|
|
312
|
+
<span v-if="minimum !== null" class="hint" style="margin-left: 8px">
|
|
313
|
+
minimum transfer {{ chain.formatGeeq(minimum) }} geeq {{ penniesLabel(minimum) }}
|
|
314
|
+
</span>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<div v-if="buildError" class="warn" data-testid="transfer-error">
|
|
319
|
+
<template v-if="buildError.code === 'insufficient-funds'">
|
|
320
|
+
<strong>Not enough coin.</strong> {{ buildError.message }}
|
|
321
|
+
</template>
|
|
322
|
+
<template v-else-if="buildError.code === 'chain-mismatch'">
|
|
323
|
+
<strong>That address belongs to another chain.</strong>
|
|
324
|
+
{{ buildError.message }}
|
|
325
|
+
</template>
|
|
326
|
+
<template v-else-if="buildError.code === 'amount-below-minimum'">
|
|
327
|
+
<strong>Below this chain's minimum.</strong> {{ buildError.message }}
|
|
328
|
+
</template>
|
|
329
|
+
<template v-else-if="buildError.code === 'account-not-found'">
|
|
330
|
+
<strong>This account has never received coins.</strong> It cannot send what it does
|
|
331
|
+
not have — fund it first.
|
|
332
|
+
<div v-if="keypair" class="row" style="margin-top: 8px">
|
|
333
|
+
<code class="v" style="flex: 1 1 auto">{{ toBase64(keypair.accountId) }}</code>
|
|
334
|
+
<CopyButton :value="toBase64(keypair.accountId)" />
|
|
335
|
+
</div>
|
|
336
|
+
</template>
|
|
337
|
+
<template v-else>{{ buildError.message }}</template>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<template v-if="tx && chain">
|
|
341
|
+
<div class="field">
|
|
342
|
+
<div class="k">Sending</div>
|
|
343
|
+
<div class="v" data-testid="transfer-summary">
|
|
344
|
+
<template v-if="tx.nfts.length > 0">{{ tx.nfts.length }} digital asset{{ tx.nfts.length === 1 ? '' : 's' }} + </template
|
|
345
|
+
>{{ chain.formatGeeq(tx.amount) }} geeq {{ penniesLabel(tx.amount) }} → {{ toBase64(tx.to) }}
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<ChainState :chain-state="tx.chainState" nonce-of="this account" />
|
|
350
|
+
<TxFee :chain="chain" :fee="tx.fee" :amount="tx.amount" payer="this account" />
|
|
351
|
+
|
|
352
|
+
<div class="field">
|
|
353
|
+
<div class="k">Transaction hash · hex · how the chain identifies it</div>
|
|
354
|
+
<div class="v">{{ toHex(tx.transactionHash) }}</div>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<div class="field">
|
|
358
|
+
<div class="k">
|
|
359
|
+
Signed transaction · base64 · {{ tx.transaction.length }} chars ·
|
|
360
|
+
no separate payload
|
|
361
|
+
</div>
|
|
362
|
+
<div class="v wrap" data-testid="transfer-base64">{{ tx.transaction }}</div>
|
|
363
|
+
<div class="row" style="margin-top: 7px"><CopyButton :value="tx.transaction" /></div>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<h3 class="sub">Submit</h3>
|
|
367
|
+
<p class="hint">
|
|
368
|
+
A transfer is sent as the signed transaction alone, with no payload — unlike
|
|
369
|
+
evidence. It goes to the same node.
|
|
370
|
+
</p>
|
|
371
|
+
<div class="row">
|
|
372
|
+
<button
|
|
373
|
+
class="primary"
|
|
374
|
+
:disabled="submitting || expired || connection.state === 'connecting'"
|
|
375
|
+
data-testid="submit-transfer"
|
|
376
|
+
@click="submit"
|
|
377
|
+
>
|
|
378
|
+
{{ submitting ? 'Submitting…' : 'Submit to node' }}
|
|
379
|
+
</button>
|
|
380
|
+
</div>
|
|
381
|
+
<p class="hint">
|
|
382
|
+
The node's reply only means it took the transaction in. Whether it lands — the nonce
|
|
383
|
+
right, the account able to pay — is decided when the next block is built, and that
|
|
384
|
+
answer comes back over the node socket, shown below.
|
|
385
|
+
</p>
|
|
386
|
+
<TxDeadline :valid-until-block="tx.validUntilBlock" :expired="expired" />
|
|
387
|
+
<SocketWarning :connection="connection" />
|
|
388
|
+
|
|
389
|
+
<div v-if="submitError" class="warn">{{ submitError }}</div>
|
|
390
|
+
<div v-if="submitResult" class="field">
|
|
391
|
+
<div class="k">Node response</div>
|
|
392
|
+
<div class="v wrap">{{ submitResult }}</div>
|
|
393
|
+
</div>
|
|
394
|
+
<p v-if="submitResult && notTracked" class="warn" data-testid="tx-untracked">
|
|
395
|
+
<strong>Sent, but no confirmation will follow.</strong> {{ notTracked }}
|
|
396
|
+
</p>
|
|
397
|
+
</template>
|
|
398
|
+
|
|
399
|
+
<TxStatus v-if="tracked" :tracked="tracked" />
|
|
400
|
+
</section>
|
|
401
|
+
</template>
|
|
402
|
+
|
|
403
|
+
<style scoped>
|
|
404
|
+
.ctl { display: inline-flex; align-items: center; gap: 8px; }
|
|
405
|
+
.ctl input { width: 140px; }
|
|
406
|
+
.ctl.grow { flex: 1 1 auto; }
|
|
407
|
+
.ctl.grow input { width: 100%; }
|
|
408
|
+
.choice { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
409
|
+
.choice.asset { display: flex; gap: 8px; margin-top: 8px; min-width: 0; }
|
|
410
|
+
.choice.asset code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
411
|
+
.sub {
|
|
412
|
+
font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em;
|
|
413
|
+
color: var(--muted); font-weight: 600; margin: 22px 0 8px;
|
|
414
|
+
}
|
|
415
|
+
.v.wrap { word-break: break-all; white-space: normal; }
|
|
416
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* How long a built transaction can wait. A node takes it only until the chain is past
|
|
4
|
+
* `validUntilBlock`; `expired` is set when a check before submitting found it was.
|
|
5
|
+
*/
|
|
6
|
+
defineProps<{ validUntilBlock: number; expired: boolean }>()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<p class="hint" data-testid="tx-deadline">
|
|
11
|
+
A node takes it until the chain is past block
|
|
12
|
+
<strong data-testid="tx-valid-until">{{ validUntilBlock }}</strong>, about nine blocks
|
|
13
|
+
after it was built. After that it is refused, so it cannot be held and sent much later.
|
|
14
|
+
</p>
|
|
15
|
+
<p v-if="expired" class="warn" data-testid="tx-expired">
|
|
16
|
+
<strong>Too old to send.</strong> The chain is past block {{ validUntilBlock }}, so the
|
|
17
|
+
node would refuse it. Nothing was sent or charged — build and sign again.
|
|
18
|
+
</p>
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { GeeqChain, GeeqFee } from '@geeqdev/geeq-sdk'
|
|
3
|
+
import { penniesLabel } from '../pennies'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The fee the SDK worked out for a built transaction. `amount` is the coin it sends, when
|
|
7
|
+
* it sends any — evidence does not. The chain shows them in geeq, at its own unit.
|
|
8
|
+
*/
|
|
9
|
+
defineProps<{ chain: GeeqChain; fee: GeeqFee | undefined; payer: string; amount?: bigint }>()
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<div class="field" data-testid="tx-fee">
|
|
14
|
+
<div class="k">Fee · paid by {{ payer }} · from the signed bytes, as the node charges it</div>
|
|
15
|
+
<div v-if="fee" class="v">
|
|
16
|
+
<strong data-testid="tx-fee-total">{{ chain.formatGeeq(fee.total) }}</strong> geeq
|
|
17
|
+
<span data-testid="tx-fee-pennies">{{ penniesLabel(fee.total) }}</span> =
|
|
18
|
+
{{ chain.formatGeeq(fee.data) }} geeq {{ penniesLabel(fee.data) }} for {{ fee.bytes }} bytes
|
|
19
|
+
<template v-if="amount !== undefined">
|
|
20
|
+
+ {{ chain.formatGeeq(fee.coin) }} geeq {{ penniesLabel(fee.coin) }} on the amount (0.25%)
|
|
21
|
+
</template>
|
|
22
|
+
</div>
|
|
23
|
+
<div v-else class="v">Unknown — this chain does not publish its fee rate.</div>
|
|
24
|
+
<p v-if="fee && amount !== undefined" class="hint">
|
|
25
|
+
In all, {{ payer }} pays
|
|
26
|
+
<strong data-testid="tx-cost">{{ chain.formatGeeq(amount + fee.total) }}</strong> geeq
|
|
27
|
+
{{ penniesLabel(amount + fee.total) }}: {{ chain.formatGeeq(amount) }} geeq {{ penniesLabel(amount) }}
|
|
28
|
+
sent, plus the fee.
|
|
29
|
+
</p>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
|
3
|
+
import type {
|
|
4
|
+
GeeqTrackedTx,
|
|
5
|
+
GeeqTxOutcome,
|
|
6
|
+
GeeqTxState,
|
|
7
|
+
GeeqTxStatusEvent,
|
|
8
|
+
} from '@geeqdev/geeq-sdk'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One submitted transaction, followed to its verdict.
|
|
12
|
+
*
|
|
13
|
+
* The tracked handle lives in the socket, not in this component, so the verdict is still
|
|
14
|
+
* heard if the panel above rebuilds its transaction. This only mirrors what the handle
|
|
15
|
+
* says, re-reading it on every change.
|
|
16
|
+
*/
|
|
17
|
+
const props = defineProps<{ tracked: GeeqTrackedTx }>()
|
|
18
|
+
|
|
19
|
+
const status = ref<GeeqTxState>('pending')
|
|
20
|
+
const outcome = shallowRef<GeeqTxOutcome | undefined>(undefined)
|
|
21
|
+
const events = shallowRef<readonly GeeqTxStatusEvent[]>([])
|
|
22
|
+
|
|
23
|
+
let stop: (() => void) | null = null
|
|
24
|
+
watch(
|
|
25
|
+
() => props.tracked,
|
|
26
|
+
(tracked) => {
|
|
27
|
+
stop?.()
|
|
28
|
+
const sync = () => {
|
|
29
|
+
status.value = tracked.status
|
|
30
|
+
outcome.value = tracked.outcome
|
|
31
|
+
events.value = tracked.events
|
|
32
|
+
}
|
|
33
|
+
sync()
|
|
34
|
+
stop = tracked.onChange(sync)
|
|
35
|
+
},
|
|
36
|
+
{ immediate: true },
|
|
37
|
+
)
|
|
38
|
+
onBeforeUnmount(() => stop?.())
|
|
39
|
+
|
|
40
|
+
const dot = computed(
|
|
41
|
+
() =>
|
|
42
|
+
({ pending: 'loading', confirmed: 'ready', rejected: 'error', unknown: '' })[status.value],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const headline = computed(() => {
|
|
46
|
+
const settled = outcome.value
|
|
47
|
+
if (settled === undefined) {
|
|
48
|
+
return events.value.length === 0
|
|
49
|
+
? 'Waiting for the chain — about one block'
|
|
50
|
+
: 'The node flagged it; waiting in case a later block decides it'
|
|
51
|
+
}
|
|
52
|
+
if (settled.status === 'confirmed') return 'Confirmed — committed in a block'
|
|
53
|
+
if (settled.status === 'unknown') {
|
|
54
|
+
return settled.reason === 'timeout' ? 'No verdict heard' : 'No longer tracked'
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
invalid: 'Rejected when the block was built — the fee is still charged',
|
|
58
|
+
nonsane: 'Refused by the node',
|
|
59
|
+
refused: 'Refused on submission',
|
|
60
|
+
'incorrectly-included': 'Rejected — carried by a node the chain distrusts',
|
|
61
|
+
}[settled.reason]
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/** Why it was not confirmed, if it was not. */
|
|
65
|
+
const reason = computed(() =>
|
|
66
|
+
outcome.value && outcome.value.status !== 'confirmed' ? outcome.value.reason : undefined,
|
|
67
|
+
)
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<template>
|
|
71
|
+
<div class="field" data-testid="tx-status" :data-status="status" :data-reason="reason">
|
|
72
|
+
<div class="k">Verdict · heard over the node socket</div>
|
|
73
|
+
<div class="status">
|
|
74
|
+
<span class="dot" :class="dot" />
|
|
75
|
+
<strong>{{ headline }}</strong>
|
|
76
|
+
<code v-if="reason" class="reason">{{ reason }}</code>
|
|
77
|
+
</div>
|
|
78
|
+
<p
|
|
79
|
+
v-if="outcome && outcome.status !== 'confirmed'"
|
|
80
|
+
class="hint"
|
|
81
|
+
data-testid="tx-status-message"
|
|
82
|
+
>
|
|
83
|
+
{{ outcome.message }}
|
|
84
|
+
</p>
|
|
85
|
+
|
|
86
|
+
<div class="k" style="margin-top: 10px">Transaction id · base64 · what status events name</div>
|
|
87
|
+
<div class="v" data-testid="tx-id">{{ tracked.id }}</div>
|
|
88
|
+
|
|
89
|
+
<ol v-if="events.length" class="events" data-testid="tx-events">
|
|
90
|
+
<li v-for="(event, index) in events" :key="index">
|
|
91
|
+
<code>{{ event.verdict }}</code>
|
|
92
|
+
<span v-if="event.blockNumber !== undefined" class="hint"> · block {{ event.blockNumber }}</span>
|
|
93
|
+
<span v-if="event.direction" class="hint"> · {{ event.direction }}</span>
|
|
94
|
+
<span v-if="event.createdAt" class="hint"> · {{ event.createdAt.toLocaleTimeString() }}</span>
|
|
95
|
+
<span v-if="event.error" class="hint"> · {{ event.error }}</span>
|
|
96
|
+
</li>
|
|
97
|
+
</ol>
|
|
98
|
+
</div>
|
|
99
|
+
</template>
|
|
100
|
+
|
|
101
|
+
<style scoped>
|
|
102
|
+
.status { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
|
|
103
|
+
.reason { font-size: 12px; color: var(--muted); }
|
|
104
|
+
.events { margin: 8px 0 0 18px; padding: 0; font-size: 13px; }
|
|
105
|
+
</style>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { onScopeDispose, shallowReactive, watch } from 'vue'
|
|
2
|
+
import type { Ref } from 'vue'
|
|
3
|
+
import { GeeqSocket } from '@geeqdev/geeq-sdk'
|
|
4
|
+
import type { GeeqChain } from '@geeqdev/geeq-sdk'
|
|
5
|
+
|
|
6
|
+
export interface SocketConnection {
|
|
7
|
+
/** The open socket, or null while there is none. */
|
|
8
|
+
socket: GeeqSocket | null
|
|
9
|
+
state: 'none' | 'connecting' | 'open' | 'reconnecting' | 'closed' | 'failed'
|
|
10
|
+
/** Why it failed to open, when it did. */
|
|
11
|
+
error: string
|
|
12
|
+
/** The last few events the node pushed, newest first. */
|
|
13
|
+
lastEvents: string[]
|
|
14
|
+
retry: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The node socket for the current chain: opened when a chain is connected, closed when it
|
|
19
|
+
* goes away. One per chain, because every tracked transaction lives in it.
|
|
20
|
+
*
|
|
21
|
+
* `socketUrl` overrides where the socket is, for a local harness. Leave it empty.
|
|
22
|
+
*/
|
|
23
|
+
export function useSocketConnection(
|
|
24
|
+
chain: Ref<GeeqChain | null>,
|
|
25
|
+
socketUrl: Ref<string>,
|
|
26
|
+
): SocketConnection {
|
|
27
|
+
// Shallow, so the socket is stored as it is and never wrapped in a proxy.
|
|
28
|
+
const connection = shallowReactive<SocketConnection>({
|
|
29
|
+
socket: null,
|
|
30
|
+
state: 'none',
|
|
31
|
+
error: '',
|
|
32
|
+
lastEvents: [],
|
|
33
|
+
retry: () => {
|
|
34
|
+
if (chain.value !== null) open(chain.value)
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
function close() {
|
|
39
|
+
// Disposing also removes our listeners, and ends any pending transaction as unknown.
|
|
40
|
+
connection.socket?.dispose()
|
|
41
|
+
connection.socket = null
|
|
42
|
+
connection.state = 'none'
|
|
43
|
+
connection.error = ''
|
|
44
|
+
connection.lastEvents = []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function open(forChain: GeeqChain) {
|
|
48
|
+
close()
|
|
49
|
+
connection.state = 'connecting'
|
|
50
|
+
const url = socketUrl.value.trim()
|
|
51
|
+
try {
|
|
52
|
+
const socket = await GeeqSocket.connect(url ? { url } : { endpoint: forChain.endpoint })
|
|
53
|
+
// The chain changed while we were connecting; this socket is not wanted any more.
|
|
54
|
+
if (chain.value !== forChain) return socket.dispose()
|
|
55
|
+
|
|
56
|
+
socket.onConnectionChange((state) => (connection.state = state))
|
|
57
|
+
socket.onMessage(({ event, payload }) => {
|
|
58
|
+
connection.lastEvents = [describe(event, payload), ...connection.lastEvents].slice(0, 6)
|
|
59
|
+
})
|
|
60
|
+
connection.socket = socket
|
|
61
|
+
connection.state = socket.state
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (chain.value !== forChain) return
|
|
64
|
+
connection.state = 'failed'
|
|
65
|
+
connection.error = error instanceof Error ? error.message : String(error)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
watch(chain, (next) => (next ? open(next) : close()), { immediate: true })
|
|
70
|
+
onScopeDispose(close)
|
|
71
|
+
|
|
72
|
+
return connection
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function describe(event: string, payload: unknown): string {
|
|
76
|
+
const data = (payload ?? {}) as Record<string, unknown>
|
|
77
|
+
if (event === 'uut_status') return `${event} ${data.status} ${String(data.refHash).slice(0, 12)}…`
|
|
78
|
+
if (typeof data.block === 'number') return `${event} block ${data.block}`
|
|
79
|
+
return event
|
|
80
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
interface ImportMetaEnv {
|
|
4
|
+
/** How many pennies make one Geeq, set in .env. */
|
|
5
|
+
readonly VITE_PENNIES_PER_GEEQ: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
declare module '*.vue' {
|
|
9
|
+
import type { DefineComponent } from 'vue'
|
|
10
|
+
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>
|
|
11
|
+
export default component
|
|
12
|
+
}
|