Compare commits

..
30 Commits
Author SHA1 Message Date
Unheard0840 83070ce195 Connect wallet transaction pagination to server API 2026-08-29 17:48:19 +02:00
Unheard0840 e56c9fde81 Update server/status.js
Add paginated address transaction API
2026-08-29 15:34:16 +00:00
Unheard0840 515ecf0ea0 Update server/status.js
Add paginated address transaction API
2026-08-29 15:28:26 +00:00
Unheard0840 9593b8ec62 Update web/index.html
Paginate wallet transactions
2026-08-29 15:17:54 +00:00
Unheard0840 810bca8154 Merge remote server updates with address cache 2026-08-29 16:44:42 +02:00
Unheard0840 0c78b1ddd5 Add persistent server-side address cache 2026-08-29 16:39:44 +02:00
Unheard0840 86b927b26d Update server/status.js
Add persistent server-side address cache
2026-08-29 14:34:47 +00:00
Unheard0840 c66bbfa618 Improve wallet transaction display 2026-08-29 16:03:09 +02:00
Unheard0840 d4c166b557 Update web/index.html 2026-08-29 13:29:10 +00:00
Unheard0840 3bd94ec41d Update web/index.html 2026-08-29 13:18:08 +00:00
Unheard0840 d1e86a6c3d Update web/index.html
Refine wallet dashboard and transaction view
2026-08-29 12:50:58 +00:00
Unheard0840 c576700272 Update web/index.html
Simplify wallet dashboard and add balance unit settings
2026-08-29 12:48:29 +00:00
Unheard0840 4598cafb1f Update web/index.html
Add BTC and sats display toggle with collapsible UTXOs
2026-08-29 12:16:05 +00:00
Unheard0840 64e4026440 Update web/index.html
Add address and transaction dashboard
2026-08-29 12:05:35 +00:00
Unheard0840 2210a525ad Update server/status.js
Add transaction lookup API
2026-08-29 11:54:17 +00:00
Unheard0840 c9bd3adcb3 Add server/transactions.js
Add Fulcrum transaction lookup module
2026-08-29 11:44:30 +00:00
Unheard0840 53b9fb183c Update server/bitcoin-address.js
Fix Bech32 checksum generator
2026-08-29 11:30:26 +00:00
Unheard0840 cf3acaa59e Update server/addresses.js
Validate Bitcoin addresses before storing
2026-08-29 11:25:59 +00:00
Unheard0840 f702b4f367 Update server/status.js
Use shared Bitcoin address module
2026-08-29 11:24:25 +00:00
Unheard0840 d68dff1707 Add server/bitcoin-address.js
Add shared Bitcoin address validation
2026-08-29 11:21:19 +00:00
Unheard0840 79d37f5dd3 Update server/addresses.js
Add Fulcrum address data queries
2026-08-29 10:50:03 +00:00
Unheard0840 6e36b3d16d Update server/status.js
Add Fulcrum address data queries
2026-08-29 10:45:13 +00:00
Unheard0840 da8928c993 Update server/status.js
Add persistent Bitcoin address watchlist
2026-08-29 10:22:31 +00:00
Unheard0840 b13136b6e4 Add server/addresses.js 2026-08-29 10:17:46 +00:00
Unheard0840 ed5213f00b Update startos/file-models/store.json.ts
Add persistent Bitcoin address watchlist
2026-08-29 10:16:44 +00:00
Unheard0840 40598a6e8e Update docs/build-notes.md
Update docs/build-notes.md
2026-08-29 08:10:54 +00:00
Unheard0840 04221619ea Update web/index.html
Display blockchain height in web interface
2026-08-29 07:58:07 +00:00
Unheard0840 badf797701 Update docs/build-notes.md
Update build notes for authentication milestone
2026-08-29 07:54:15 +00:00
Unheard0840 f1604758e7 Update startos/file-models/store.json.ts
Persist admin password in main volume
2026-08-29 07:46:56 +00:00
Unheard0840 00ec5ca002 Update docs/build-notes.md
Document completed authentication milestone
2026-08-29 07:22:28 +00:00
7 changed files with 3829 additions and 370 deletions
+33 -10
View File
@@ -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
+155
View File
@@ -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,
}
+249
View File
@@ -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,
}
+1057 -104
View File
File diff suppressed because it is too large Load Diff
+185
View File
@@ -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,
}
+11 -1
View File
@@ -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(),
}), }),
) )
+2080 -196
View File
File diff suppressed because it is too large Load Diff