Compare commits
30
Commits
9b56404e13
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83070ce195 | ||
|
|
e56c9fde81 | ||
|
|
515ecf0ea0 | ||
|
|
9593b8ec62 | ||
|
|
810bca8154 | ||
|
|
0c78b1ddd5 | ||
|
|
86b927b26d | ||
|
|
c66bbfa618 | ||
|
|
d4c166b557 | ||
|
|
3bd94ec41d | ||
|
|
d1e86a6c3d | ||
|
|
c576700272 | ||
|
|
4598cafb1f | ||
|
|
64e4026440 | ||
|
|
2210a525ad | ||
|
|
c9bd3adcb3 | ||
|
|
53b9fb183c | ||
|
|
cf3acaa59e | ||
|
|
f702b4f367 | ||
|
|
d68dff1707 | ||
|
|
79d37f5dd3 | ||
|
|
6e36b3d16d | ||
|
|
da8928c993 | ||
|
|
b13136b6e4 | ||
|
|
ed5213f00b | ||
|
|
40598a6e8e | ||
|
|
04221619ea | ||
|
|
badf797701 | ||
|
|
f1604758e7 | ||
|
|
00ec5ca002 |
+33
-10
@@ -40,7 +40,29 @@
|
|||||||
- Build the backend foundation for wallet monitoring
|
- Build the backend foundation for wallet monitoring
|
||||||
- Query current blockchain height through `blockchain.headers.subscribe`
|
- Query current blockchain height through `blockchain.headers.subscribe`
|
||||||
- Display Fulcrum server version and blockchain height through the status API
|
- Display Fulcrum server version and blockchain height through the status API
|
||||||
|
- Display the current blockchain height in the Munin web interface
|
||||||
- Verify live blockchain height from the physical StartOS server
|
- Verify live blockchain height from the physical StartOS server
|
||||||
|
- Verify blockchain height display after rebuilding and reinstalling the S9PK package
|
||||||
|
|
||||||
|
### Milestone 4 — Authentication and access control
|
||||||
|
|
||||||
|
- Add user authentication for the Munin web interface
|
||||||
|
- Require authentication before accessing wallet and monitoring data
|
||||||
|
- Add password management through StartOS `Actions & Config`
|
||||||
|
- Store the Munin admin password in the persistent StartOS `main` volume
|
||||||
|
- Pass the stored admin password to the Munin container through `MUNIN_ADMIN_PASSWORD`
|
||||||
|
- Protect the status API from unauthenticated access
|
||||||
|
- Use secure, HTTP-only, SameSite session cookies
|
||||||
|
- Use cryptographically random session tokens
|
||||||
|
- Use constant-time password comparison
|
||||||
|
- Expire sessions after 24 hours
|
||||||
|
- Clean up expired sessions periodically
|
||||||
|
- Display a login form in the web interface
|
||||||
|
- Display authentication errors in the web interface
|
||||||
|
- Verify authentication on the physical StartOS server
|
||||||
|
- Verify that the configured password survives a package update
|
||||||
|
- Verify that authenticated access to the status API continues to work after the package update
|
||||||
|
- Verify that the existing password remains valid after rebuilding and reinstalling the S9PK package
|
||||||
|
|
||||||
## Current architecture
|
## Current architecture
|
||||||
|
|
||||||
@@ -52,12 +74,22 @@
|
|||||||
- Fulcrum provided as a StartOS dependency
|
- Fulcrum provided as a StartOS dependency
|
||||||
- Electrum protocol connectivity check from Munin to Fulcrum
|
- Electrum protocol connectivity check from Munin to Fulcrum
|
||||||
- Blockchain height queried through Fulcrum
|
- Blockchain height queried through Fulcrum
|
||||||
|
- Blockchain height displayed in the web interface
|
||||||
|
- StartOS `main` volume used for persistent Munin data
|
||||||
|
- Admin password stored persistently in the StartOS `main` volume
|
||||||
|
- Session-based authentication for the web interface
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Munin Bitcoin is running as a StartOS service and can verify connectivity to the configured Fulcrum Electrum server.
|
Munin Bitcoin is running as a StartOS service and can verify connectivity to the configured Fulcrum Electrum server.
|
||||||
|
|
||||||
Munin can query the current Bitcoin blockchain height through Fulcrum.
|
Munin can query the current Bitcoin blockchain height through Fulcrum and display it in the web interface.
|
||||||
|
|
||||||
|
The Munin web interface is protected by password authentication.
|
||||||
|
|
||||||
|
The admin password can be configured through StartOS `Actions & Config` and is stored in the persistent `main` volume so that it survives package updates and reinstallations.
|
||||||
|
|
||||||
|
Authentication and password persistence have been verified on the physical StartOS server, including verification that an existing password remains valid after rebuilding and reinstalling the package.
|
||||||
|
|
||||||
Bitcoin Core RPC is not currently used by Munin.
|
Bitcoin Core RPC is not currently used by Munin.
|
||||||
|
|
||||||
@@ -65,15 +97,6 @@ Wallet configuration, label storage, transaction scanning, and notification func
|
|||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|
||||||
### Milestone 4 — Authentication and access control
|
|
||||||
|
|
||||||
- Add user authentication for the Munin web interface
|
|
||||||
- Require authentication before accessing wallet and monitoring data
|
|
||||||
- Add password management
|
|
||||||
- Protect wallet addresses, xpubs, BSMS data, labels, and transaction information
|
|
||||||
- Keep authentication credentials separate from wallet data
|
|
||||||
- Integrate with StartOS authentication/security conventions where appropriate
|
|
||||||
|
|
||||||
### Milestone 5 — Address watchlist
|
### Milestone 5 — Address watchlist
|
||||||
|
|
||||||
- Add watch-only Bitcoin addresses
|
- Add watch-only Bitcoin addresses
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs/promises')
|
||||||
|
|
||||||
|
const {
|
||||||
|
addressToScriptPubKey,
|
||||||
|
} = require('./bitcoin-address')
|
||||||
|
|
||||||
|
const storePath = '/data/store.json'
|
||||||
|
|
||||||
|
async function readStore() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(storePath, 'utf8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeStore(store) {
|
||||||
|
const temporaryPath =
|
||||||
|
`${storePath}.${crypto.randomBytes(8).toString('hex')}.tmp`
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
temporaryPath,
|
||||||
|
JSON.stringify(store, null, 2) + '\n',
|
||||||
|
{
|
||||||
|
mode: 0o600,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await fs.rename(temporaryPath, storePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAddress(address) {
|
||||||
|
if (typeof address !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = address.trim()
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLabel(label) {
|
||||||
|
if (typeof label !== 'string') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = label.trim()
|
||||||
|
|
||||||
|
return normalized || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBitcoinAddress(address) {
|
||||||
|
try {
|
||||||
|
addressToScriptPubKey(address)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Invalid Bitcoin address',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAddresses() {
|
||||||
|
const store = await readStore()
|
||||||
|
|
||||||
|
return Array.isArray(store.addresses)
|
||||||
|
? store.addresses
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAddress(address, label) {
|
||||||
|
const normalizedAddress = normalizeAddress(address)
|
||||||
|
|
||||||
|
if (!normalizedAddress) {
|
||||||
|
throw new Error('Bitcoin address is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
validateBitcoinAddress(normalizedAddress)
|
||||||
|
|
||||||
|
const addresses = await listAddresses()
|
||||||
|
|
||||||
|
if (
|
||||||
|
addresses.some(
|
||||||
|
(item) => item.address === normalizedAddress,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Bitcoin address is already being watched',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
address: normalizedAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLabel = normalizeLabel(label)
|
||||||
|
|
||||||
|
if (normalizedLabel) {
|
||||||
|
entry.label = normalizedLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await readStore()
|
||||||
|
|
||||||
|
store.addresses = [...addresses, entry]
|
||||||
|
|
||||||
|
await writeStore(store)
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAddress(address) {
|
||||||
|
const normalizedAddress = normalizeAddress(address)
|
||||||
|
|
||||||
|
if (!normalizedAddress) {
|
||||||
|
throw new Error('Bitcoin address is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await readStore()
|
||||||
|
const addresses = Array.isArray(store.addresses)
|
||||||
|
? store.addresses
|
||||||
|
: []
|
||||||
|
|
||||||
|
const filtered = addresses.filter(
|
||||||
|
(item) => item.address !== normalizedAddress,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filtered.length === addresses.length) {
|
||||||
|
throw new Error(
|
||||||
|
'Bitcoin address is not being watched',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.addresses = filtered
|
||||||
|
|
||||||
|
await writeStore(store)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listAddresses,
|
||||||
|
addAddress,
|
||||||
|
removeAddress,
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
const BECH32_CHARSET =
|
||||||
|
'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
||||||
|
|
||||||
|
function bech32Polymod(values) {
|
||||||
|
const generators = [
|
||||||
|
0x3b6a57b2,
|
||||||
|
0x26508e6d,
|
||||||
|
0x1ea119fa,
|
||||||
|
0x3d4233dd,
|
||||||
|
0x2a1462b3,
|
||||||
|
]
|
||||||
|
|
||||||
|
let chk = 1
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
const top = chk >>> 25
|
||||||
|
chk = ((chk & 0x1ffffff) << 5) ^ value
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
if ((top >>> i) & 1) {
|
||||||
|
chk ^= generators[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chk >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function bech32HrpExpand(hrp) {
|
||||||
|
const values = []
|
||||||
|
|
||||||
|
for (const char of hrp) {
|
||||||
|
values.push(char.charCodeAt(0) >> 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(0)
|
||||||
|
|
||||||
|
for (const char of hrp) {
|
||||||
|
values.push(char.charCodeAt(0) & 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
function bech32VerifyChecksum(hrp, data) {
|
||||||
|
const polymod = bech32Polymod([
|
||||||
|
...bech32HrpExpand(hrp),
|
||||||
|
...data,
|
||||||
|
])
|
||||||
|
|
||||||
|
return polymod === 1 || polymod === 0x2bc830a3
|
||||||
|
}
|
||||||
|
|
||||||
|
function bech32Decode(address) {
|
||||||
|
if (typeof address !== 'string') {
|
||||||
|
throw new Error('Bitcoin address must be a string')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address.length < 8 || address.length > 90) {
|
||||||
|
throw new Error('Invalid Bitcoin address length')
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLower = address !== address.toUpperCase()
|
||||||
|
const hasUpper = address !== address.toLowerCase()
|
||||||
|
|
||||||
|
if (hasLower && hasUpper) {
|
||||||
|
throw new Error(
|
||||||
|
'Bitcoin address must not mix uppercase and lowercase',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = address.toLowerCase()
|
||||||
|
const separator = normalized.lastIndexOf('1')
|
||||||
|
|
||||||
|
if (separator < 1 || separator + 7 > normalized.length) {
|
||||||
|
throw new Error('Invalid Bech32 separator')
|
||||||
|
}
|
||||||
|
|
||||||
|
const hrp = normalized.slice(0, separator)
|
||||||
|
const dataPart = normalized.slice(separator + 1)
|
||||||
|
const data = []
|
||||||
|
|
||||||
|
for (const char of dataPart) {
|
||||||
|
const value = BECH32_CHARSET.indexOf(char)
|
||||||
|
|
||||||
|
if (value === -1) {
|
||||||
|
throw new Error('Invalid Bech32 character')
|
||||||
|
}
|
||||||
|
|
||||||
|
data.push(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bech32VerifyChecksum(hrp, data)) {
|
||||||
|
throw new Error('Invalid Bitcoin address checksum')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hrp,
|
||||||
|
data: data.slice(0, -6),
|
||||||
|
spec:
|
||||||
|
bech32Polymod([
|
||||||
|
...bech32HrpExpand(hrp),
|
||||||
|
...data,
|
||||||
|
]) === 1
|
||||||
|
? 'bech32'
|
||||||
|
: 'bech32m',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertBits(data, fromBits, toBits, pad) {
|
||||||
|
let accumulator = 0
|
||||||
|
let bits = 0
|
||||||
|
const result = []
|
||||||
|
const maxValue = (1 << toBits) - 1
|
||||||
|
|
||||||
|
for (const value of data) {
|
||||||
|
if (value < 0 || value >> fromBits !== 0) {
|
||||||
|
throw new Error('Invalid bit conversion input')
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulator =
|
||||||
|
(accumulator << fromBits) | value
|
||||||
|
bits += fromBits
|
||||||
|
|
||||||
|
while (bits >= toBits) {
|
||||||
|
bits -= toBits
|
||||||
|
result.push(
|
||||||
|
(accumulator >> bits) & maxValue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pad) {
|
||||||
|
if (bits > 0) {
|
||||||
|
result.push(
|
||||||
|
(accumulator << (toBits - bits)) & maxValue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (bits >= fromBits) {
|
||||||
|
throw new Error('Invalid padding')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
((accumulator << (toBits - bits)) &
|
||||||
|
maxValue) !==
|
||||||
|
0
|
||||||
|
) {
|
||||||
|
throw new Error('Non-zero padding')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressToScriptPubKey(address) {
|
||||||
|
const decoded = bech32Decode(address)
|
||||||
|
|
||||||
|
if (decoded.hrp !== 'bc') {
|
||||||
|
throw new Error(
|
||||||
|
'Only mainnet Bitcoin addresses are supported',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded.data.length < 1) {
|
||||||
|
throw new Error('Invalid SegWit address')
|
||||||
|
}
|
||||||
|
|
||||||
|
const witnessVersion = decoded.data[0]
|
||||||
|
|
||||||
|
if (witnessVersion > 16) {
|
||||||
|
throw new Error('Invalid SegWit witness version')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
witnessVersion === 0 &&
|
||||||
|
decoded.spec !== 'bech32'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Witness version 0 must use Bech32',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
witnessVersion !== 0 &&
|
||||||
|
decoded.spec !== 'bech32m'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Witness version 1+ must use Bech32m',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = convertBits(
|
||||||
|
decoded.data.slice(1),
|
||||||
|
5,
|
||||||
|
8,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (program.length < 2 || program.length > 40) {
|
||||||
|
throw new Error('Invalid witness program length')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
witnessVersion === 0 &&
|
||||||
|
program.length !== 20 &&
|
||||||
|
program.length !== 32
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Invalid witness version 0 program length',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionOpcode =
|
||||||
|
witnessVersion === 0
|
||||||
|
? 0x00
|
||||||
|
: 0x50 + witnessVersion
|
||||||
|
|
||||||
|
return Buffer.from([
|
||||||
|
versionOpcode,
|
||||||
|
program.length,
|
||||||
|
...program,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptPubKeyToScripthash(scriptPubKey) {
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(scriptPubKey)
|
||||||
|
.digest()
|
||||||
|
|
||||||
|
return Buffer.from(hash)
|
||||||
|
.reverse()
|
||||||
|
.toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressToScripthash(address) {
|
||||||
|
return scriptPubKeyToScripthash(
|
||||||
|
addressToScriptPubKey(address),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
addressToScriptPubKey,
|
||||||
|
scriptPubKeyToScripthash,
|
||||||
|
addressToScripthash,
|
||||||
|
}
|
||||||
+1100
-147
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
|||||||
|
const net = require('net')
|
||||||
|
|
||||||
|
const fulcrumTimeoutMs = 3000
|
||||||
|
|
||||||
|
function queryFulcrum(fulcrumUrl, method, params = []) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!fulcrumUrl) {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
result: null,
|
||||||
|
error: 'Fulcrum URL is not configured',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = new URL(fulcrumUrl)
|
||||||
|
} catch {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
result: null,
|
||||||
|
error: 'Invalid Fulcrum URL',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'tcp:') {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
result: null,
|
||||||
|
error: 'Fulcrum URL must use tcp://',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.hostname
|
||||||
|
const port = Number(parsed.port)
|
||||||
|
|
||||||
|
if (!host || !port) {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
result: null,
|
||||||
|
error: 'Fulcrum URL is missing host or port',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = net.createConnection({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
})
|
||||||
|
|
||||||
|
let response = ''
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
const finish = (value) => {
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true
|
||||||
|
socket.destroy()
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.setTimeout(fulcrumTimeoutMs)
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
const request =
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
}) + '\n'
|
||||||
|
|
||||||
|
socket.write(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('data', (data) => {
|
||||||
|
response += data.toString()
|
||||||
|
|
||||||
|
const lines = response.split('\n')
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let message
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = JSON.parse(line)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.error) {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
result: null,
|
||||||
|
error: message.error,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(
|
||||||
|
message,
|
||||||
|
'result',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
finish({
|
||||||
|
ok: true,
|
||||||
|
result: message.result,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
result: null,
|
||||||
|
error: 'Connection timed out',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
result: null,
|
||||||
|
error: error.message,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (!settled) {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
result: null,
|
||||||
|
error:
|
||||||
|
'Connection closed before a response was received',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTransaction(txid) {
|
||||||
|
if (typeof txid !== 'string' || !txid.trim()) {
|
||||||
|
throw new Error('Transaction ID is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fulcrumUrl =
|
||||||
|
process.env.MUNIN_FULCRUM_URL || null
|
||||||
|
|
||||||
|
if (!fulcrumUrl) {
|
||||||
|
throw new Error('Fulcrum URL is not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryFulcrum(
|
||||||
|
fulcrumUrl,
|
||||||
|
'blockchain.transaction.get',
|
||||||
|
[txid.trim(), true],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to query transaction: ${JSON.stringify(result.error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.result
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getTransaction,
|
||||||
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
import { FileHelper, z } from '@start9labs/start-sdk'
|
import { FileHelper, z } from '@start9labs/start-sdk'
|
||||||
|
import { sdk } from '../sdk'
|
||||||
|
|
||||||
export const store = FileHelper.json(
|
export const store = FileHelper.json(
|
||||||
'./store.json',
|
{
|
||||||
|
base: sdk.volumes.main,
|
||||||
|
subpath: './store.json',
|
||||||
|
},
|
||||||
z.object({
|
z.object({
|
||||||
adminPassword: z.string().optional(),
|
adminPassword: z.string().optional(),
|
||||||
|
addresses: z.array(
|
||||||
|
z.object({
|
||||||
|
address: z.string(),
|
||||||
|
label: z.string().optional(),
|
||||||
|
}),
|
||||||
|
).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
+2095
-211
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user