chore: dev environment — signet testnet stack, mock LND RPCs, faucet button

Switch docker-compose from regtest to signet, add standalone testnet stack
(docker-compose.testnet.yml) with Bitcoin+LND+ThunderHub+Fedimint. Mock
backend now auto-detects Podman/Docker sockets and includes full LND/Lightning
RPC mocks. Dev scripts refactored with boot mode, testnet option, and macOS
EAGAIN fix for port cleanup. Added dev faucet button to Home.vue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-18 21:06:14 +00:00
parent e9da567116
commit db2ad27340
10 changed files with 958 additions and 218 deletions

View File

@@ -22,7 +22,38 @@ const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const execPromise = promisify(exec)
const docker = new Docker()
// Find container socket: Podman (macOS/Linux) or Docker
import { existsSync } from 'fs'
function findContainerSocket() {
// DOCKER_HOST env var (set by podman machine start)
if (process.env.DOCKER_HOST) {
const p = process.env.DOCKER_HOST.replace('unix://', '')
if (existsSync(p)) return p
}
// Podman machine socket (macOS) — check TMPDIR-based path
if (process.env.TMPDIR) {
const podmanDir = path.join(path.dirname(process.env.TMPDIR), 'podman')
const sock = path.join(podmanDir, 'podman-machine-default-api.sock')
if (existsSync(sock)) return sock
}
// Docker socket
if (existsSync('/var/run/docker.sock')) return '/var/run/docker.sock'
// Linux podman rootless
const uid = process.getuid?.() || 1000
const linuxSock = `/run/user/${uid}/podman/podman.sock`
if (existsSync(linuxSock)) return linuxSock
return null
}
const containerSocket = findContainerSocket()
const docker = containerSocket ? new Docker({ socketPath: containerSocket }) : null
if (containerSocket) {
console.log(`[Container] Socket: ${containerSocket}`)
} else {
console.log('[Container] No socket found — simulation mode (no Docker/Podman)')
}
const app = express()
const PORT = 5959
@@ -176,6 +207,7 @@ let nextAutoPort = 8200
// Helper: Query real Docker containers
async function getDockerContainers() {
if (!docker) return {}
try {
const containers = await docker.listContainers({ all: true })
@@ -1869,6 +1901,302 @@ app.post('/rpc/v1', (req, res) => {
return res.json({ result: { mesh_only: meshOnly, configured: true } })
}
// =====================================================================
// LND / Lightning
// =====================================================================
case 'lnd.getinfo': {
return res.json({
result: {
alias: 'archy-signet',
color: '#f7931a',
num_active_channels: 4,
num_inactive_channels: 1,
num_pending_channels: 1,
block_height: 892451,
synced_to_chain: true,
synced_to_graph: true,
version: '0.17.4-beta',
identity_pubkey: '03a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
chains: [{ chain: 'bitcoin', network: 'signet' }],
// Balances (Home.vue reads these from getinfo)
balance_sats: 2_350_000,
channel_balance_sats: 8_250_000,
},
})
}
case 'lnd.gettransactions': {
return res.json({
result: {
transactions: [
{ tx_hash: 'ab12cd34ef5678901234567890abcdef12345678', amount_sats: 2_000_000, num_confirmations: 142, block_height: 892310, time_stamp: Math.floor(Date.now()/1000) - 86400, label: 'Channel funding' },
{ tx_hash: 'cd34ef5678901234567890abcdef1234567890ab', amount_sats: 250_000, num_confirmations: 28, block_height: 892420, time_stamp: Math.floor(Date.now()/1000) - 7200, label: 'Faucet deposit' },
{ tx_hash: 'ff99ee88dd7766554433221100aabbccddeeff00', amount_sats: 100_000, num_confirmations: 0, block_height: 0, time_stamp: Math.floor(Date.now()/1000) - 600, label: 'Incoming from faucet' },
],
incoming_pending_count: 1,
},
})
}
case 'lnd.channelbalance': {
return res.json({
result: {
local_balance: { sat: 8250000 },
remote_balance: { sat: 11750000 },
pending_open_local_balance: { sat: 500000 },
},
})
}
case 'lnd.walletbalance': {
return res.json({
result: {
total_balance: 2450000,
confirmed_balance: 2350000,
unconfirmed_balance: 100000,
},
})
}
case 'lnd.listchannels': {
return res.json({
result: {
channels: [
{ chan_id: '840921088114688', remote_pubkey: '02778f4a', capacity: 5000000, local_balance: 2450000, remote_balance: 2550000, active: true, peer_alias: 'ACINQ Signet' },
{ chan_id: '840921088114689', remote_pubkey: '03abcdef', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, peer_alias: 'WalletOfSatoshi' },
{ chan_id: '840921088114690', remote_pubkey: '02fedcba', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, peer_alias: 'Voltage' },
{ chan_id: '840921088114691', remote_pubkey: '03456789', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: true, peer_alias: 'Kraken' },
],
},
})
}
case 'lnd.newaddress': {
const addrType = params?.type || 'p2wkh'
const mockAddr = addrType === 'p2tr'
? 'tb1p' + Array.from({length: 58}, () => '0123456789abcdef'[Math.floor(Math.random()*16)]).join('')
: 'tb1q' + Array.from({length: 38}, () => '0123456789abcdef'[Math.floor(Math.random()*16)]).join('')
return res.json({ result: { address: mockAddr } })
}
case 'lnd.addinvoice':
case 'lnd.createinvoice': {
const amt = params?.amt || params?.value || params?.amount_sats || 1000
const memo = params?.memo || ''
const rHash = Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join('')
return res.json({
result: {
r_hash: rHash,
payment_request: `lnsb${amt}n1pjmock${Date.now().toString(36)}qqqxqyz5vqsp5mock${rHash.slice(0,20)}`,
add_index: Date.now(),
payment_addr: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''),
},
})
}
case 'lnd.payinvoice':
case 'lnd.sendpayment': {
return res.json({
result: {
payment_hash: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''),
payment_preimage: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''),
status: 'SUCCEEDED',
fee_sat: Math.floor(Math.random() * 10) + 1,
value_sat: params?.amt || params?.amount_sats || 1000,
},
})
}
case 'lnd.sendcoins': {
const amt = params?.amount || params?.amt || 50000
return res.json({
result: {
txid: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''),
amount: amt,
},
})
}
case 'lnd.decodepayreq': {
return res.json({
result: {
destination: '03a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
num_satoshis: params?.pay_req?.match(/lnsb(\d+)/)?.[1] || '1000',
description: 'Mock decoded invoice',
expiry: 3600,
timestamp: Math.floor(Date.now() / 1000),
},
})
}
case 'lnd.openchannel': {
return res.json({
result: {
funding_txid: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''),
output_index: 0,
},
})
}
case 'lnd.closechannel': {
return res.json({
result: {
closing_txid: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''),
},
})
}
case 'lnd.listinvoices': {
return res.json({ result: { invoices: MOCK_LND_DATA.invoices } })
}
case 'lnd.listpayments': {
return res.json({ result: { payments: MOCK_LND_DATA.payments } })
}
case 'lnd.create-psbt':
case 'lnd.finalize-psbt':
case 'lnd.create-raw-tx': {
return res.json({
result: {
psbt: 'cHNidP8BAH0CAAAA...mockPSBT',
txid: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''),
},
})
}
// =====================================================================
// Wallet / Ecash (Fedimint)
// =====================================================================
case 'wallet.ecash-balance': {
return res.json({
result: {
balance_sats: 250_000,
balance_msat: 250_000_000,
token_count: 12,
federations: [
{ federation_id: 'fed1-demo', name: 'Archy Signet Mint', balance_msat: 250_000_000, gateway_active: true },
],
},
})
}
case 'wallet.ecash-send': {
const amt = params?.amount_sats || 1000
return res.json({
result: {
token: `cashuSend_mock_${amt}_${Date.now().toString(36)}`,
amount_sats: amt,
},
})
}
case 'wallet.ecash-receive': {
return res.json({
result: {
amount_sats: 5000,
federation_id: 'fed1-demo',
},
})
}
case 'wallet.ecash-history': {
return res.json({
result: {
transactions: [
{ type: 'receive', amount_sats: 50000, timestamp: new Date(Date.now() - 86400000).toISOString(), note: 'Minted from Lightning' },
{ type: 'send', amount_sats: 5000, timestamp: new Date(Date.now() - 43200000).toISOString(), note: 'Sent ecash token' },
{ type: 'receive', amount_sats: 10000, timestamp: new Date(Date.now() - 3600000).toISOString(), note: 'Redeemed token' },
],
},
})
}
case 'wallet.networking-profits': {
return res.json({
result: {
total_earned_sats: 42,
total_forwarded_sats: 35025,
forward_count: 3,
period_days: 30,
daily_avg_sats: 1.4,
},
})
}
case 'dev.faucet': {
// Dev-only: add mock funds to all wallet types
const amount = params?.amount_sats || 1_000_000
console.log(`[Dev Faucet] Adding ${amount} sats to all wallets`)
return res.json({
result: {
onchain: { txid: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''), amount_sats: amount },
lightning: { payment_hash: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''), amount_sats: amount },
ecash: { token: `cashuSend_faucet_${amount}_${Date.now().toString(36)}`, amount_sats: Math.floor(amount / 10) },
message: `Added ${amount} sats on-chain, ${amount} sats Lightning, ${Math.floor(amount / 10)} sats ecash`,
},
})
}
case 'bitcoin.getinfo': {
return res.json({
result: {
chain: 'signet',
blocks: 892451,
headers: 892451,
bestblockhash: 'a1b2c3d4e5f6' + '0'.repeat(58),
difficulty: 0.001126515290698186,
mediantime: Math.floor(Date.now() / 1000) - 300,
verificationprogress: 1.0,
chainwork: '000000000000000000000000000000000000000000000000000000000001a2b3',
size_on_disk: 210_000_000,
pruned: false,
network: 'signet',
},
})
}
// =====================================================================
// System / Network / Updates
// =====================================================================
case 'system.stats': {
return res.json({
result: {
cpu_percent: +(12 + Math.random() * 18).toFixed(1),
mem_used_bytes: 6_200_000_000 + Math.floor(Math.random() * 500_000_000),
mem_total_bytes: 16_000_000_000,
disk_used_bytes: 620_000_000_000 + Math.floor(Math.random() * 10_000_000_000),
disk_total_bytes: 1_800_000_000_000,
uptime_secs: Math.floor(process.uptime()) + 604800,
load_avg: [+(0.5 + Math.random() * 1.5).toFixed(2), +(0.8 + Math.random()).toFixed(2), +(0.6 + Math.random()).toFixed(2)],
net_rx_bytes: 12_400_000_000,
net_tx_bytes: 8_900_000_000,
},
})
}
case 'update.status': {
return res.json({
result: {
current_version: '0.1.0',
latest_version: '0.1.1',
update_available: true,
release_notes: 'Bug fixes and performance improvements.',
channel: 'stable',
},
})
}
case 'network.list-requests': {
return res.json({
result: {
requests: [
{ id: 'req-1', from_did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', from_name: 'archy-198', type: 'federation-join', status: 'pending', created_at: new Date(Date.now() - 3600000).toISOString() },
],
},
})
}
default: {
console.log(`[RPC] Unknown method: ${method}`)
return res.json({
@@ -2192,6 +2520,169 @@ app.all('/aiui/api/*', (req, res) => {
res.status(404).json({ error: 'Not available in demo mode' })
})
// =============================================================================
// Mock ThunderHub UI + Lightning API (no Docker required)
// =============================================================================
const MOCK_LND_DATA = {
info: {
alias: 'archy-signet',
color: '#f7931a',
public_key: '03a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
num_active_channels: 4,
num_inactive_channels: 1,
num_pending_channels: 1,
block_height: 892451,
synced_to_chain: true,
synced_to_graph: true,
version: '0.17.4-beta commit=v0.17.4-beta',
chains: [{ chain: 'bitcoin', network: 'signet' }],
uris: ['03a1b2c3@archy-signet.onion:9735'],
},
balance: {
total_balance: 2_450_000,
confirmed_balance: 2_350_000,
unconfirmed_balance: 100_000,
},
channelBalance: {
local_balance: { sat: 8_250_000 },
remote_balance: { sat: 11_750_000 },
pending_open_local_balance: { sat: 500_000 },
pending_open_remote_balance: { sat: 0 },
},
channels: [
{ chan_id: '840921088114688', remote_pubkey: '02778f4a4e...acinq', capacity: 5_000_000, local_balance: 2_450_000, remote_balance: 2_550_000, active: true, peer_alias: 'ACINQ Signet', total_satoshis_sent: 850_000, total_satoshis_received: 1_200_000, uptime: 604800, lifetime: 2592000 },
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12...wos', capacity: 2_000_000, local_balance: 1_200_000, remote_balance: 800_000, active: true, peer_alias: 'WalletOfSatoshi', total_satoshis_sent: 350_000, total_satoshis_received: 500_000, uptime: 259200, lifetime: 1296000 },
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98...voltage', capacity: 10_000_000, local_balance: 4_500_000, remote_balance: 5_500_000, active: true, peer_alias: 'Voltage', total_satoshis_sent: 2_100_000, total_satoshis_received: 1_800_000, uptime: 518400, lifetime: 2592000 },
{ chan_id: '840921088114691', remote_pubkey: '03456789ab...kraken', capacity: 3_000_000, local_balance: 100_000, remote_balance: 2_900_000, active: true, peer_alias: 'Kraken', total_satoshis_sent: 50_000, total_satoshis_received: 120_000, uptime: 86400, lifetime: 604800 },
{ chan_id: '840921088114692', remote_pubkey: '02112233aa...old', capacity: 1_000_000, local_balance: 0, remote_balance: 1_000_000, active: false, peer_alias: 'OldPeer-Offline', total_satoshis_sent: 0, total_satoshis_received: 0, uptime: 0, lifetime: 5184000 },
],
pendingChannels: {
pending_open_channels: [
{ channel: { remote_node_pub: '03ffeeddcc...newpeer', capacity: 500_000, local_balance: 500_000, remote_balance: 0 }, confirmation_height: 892452 },
],
pending_closing_channels: [],
pending_force_closing_channels: [],
waiting_close_channels: [],
},
invoices: [
{ memo: 'Channel opening fee', value: 50_000, settled: true, creation_date: Math.floor(Date.now()/1000) - 86400, settle_date: Math.floor(Date.now()/1000) - 85800, payment_request: 'lnsb500000n1pjtest...truncated', state: 'SETTLED', amt_paid_sat: 50_000, r_hash: Buffer.from('aabbccdd01').toString('hex') },
{ memo: 'Test payment', value: 1_000, settled: true, creation_date: Math.floor(Date.now()/1000) - 7200, settle_date: Math.floor(Date.now()/1000) - 7100, payment_request: 'lnsb10000n1pjtest2...truncated', state: 'SETTLED', amt_paid_sat: 1_000, r_hash: Buffer.from('aabbccdd02').toString('hex') },
{ memo: 'Coffee payment', value: 5_000, settled: true, creation_date: Math.floor(Date.now()/1000) - 3600, settle_date: Math.floor(Date.now()/1000) - 3500, payment_request: 'lnsb50000n1pjtest3...truncated', state: 'SETTLED', amt_paid_sat: 5_000, r_hash: Buffer.from('aabbccdd03').toString('hex') },
{ memo: 'Donation', value: 21_000, settled: false, creation_date: Math.floor(Date.now()/1000) - 600, settle_date: 0, payment_request: 'lnsb210000n1pjtest4...truncated', state: 'OPEN', amt_paid_sat: 0, r_hash: Buffer.from('aabbccdd04').toString('hex') },
],
payments: [
{ payment_hash: 'ff00112233', value_sat: 10_000, fee_sat: 3, status: 'SUCCEEDED', creation_date: Math.floor(Date.now()/1000) - 43200, payment_request: 'lnsb100000n1pjpay1...', failure_reason: 'FAILURE_REASON_NONE' },
{ payment_hash: 'ff00112234', value_sat: 100_000, fee_sat: 12, status: 'SUCCEEDED', creation_date: Math.floor(Date.now()/1000) - 21600, payment_request: 'lnsb1000000n1pjpay2...', failure_reason: 'FAILURE_REASON_NONE' },
{ payment_hash: 'ff00112235', value_sat: 500, fee_sat: 1, status: 'SUCCEEDED', creation_date: Math.floor(Date.now()/1000) - 1800, payment_request: 'lnsb5000n1pjpay3...', failure_reason: 'FAILURE_REASON_NONE' },
],
forwarding: [
{ chan_id_in: '840921088114688', chan_id_out: '840921088114690', amt_in: 10_012, amt_out: 10_000, fee: 12, timestamp_ns: (Date.now() - 7200000) * 1e6 },
{ chan_id_in: '840921088114690', chan_id_out: '840921088114689', amt_in: 5_005, amt_out: 5_000, fee: 5, timestamp_ns: (Date.now() - 3600000) * 1e6 },
{ chan_id_in: '840921088114689', chan_id_out: '840921088114691', amt_in: 25_025, amt_out: 25_000, fee: 25, timestamp_ns: (Date.now() - 1200000) * 1e6 },
],
}
// ThunderHub mock web UI
app.get('/app/thunderhub/', (req, res) => {
const d = MOCK_LND_DATA
const totalCap = d.channels.reduce((s, c) => s + c.capacity, 0)
const totalLocal = d.channels.reduce((s, c) => s + c.local_balance, 0)
const totalRemote = d.channels.reduce((s, c) => s + c.remote_balance, 0)
const totalFees = d.forwarding.reduce((s, f) => s + f.fee, 0)
const channelRows = d.channels.map(c => `
<tr>
<td>${c.peer_alias}</td>
<td>${(c.capacity/1e6).toFixed(1)}M</td>
<td><div style="display:flex;gap:4px;align-items:center"><div style="background:#4ade80;height:8px;width:${Math.round(c.local_balance/c.capacity*100)}%;border-radius:4px"></div><span style="font-size:11px;opacity:.6">${(c.local_balance/1e3).toFixed(0)}k</span></div></td>
<td><div style="display:flex;gap:4px;align-items:center"><div style="background:#3b82f6;height:8px;width:${Math.round(c.remote_balance/c.capacity*100)}%;border-radius:4px"></div><span style="font-size:11px;opacity:.6">${(c.remote_balance/1e3).toFixed(0)}k</span></div></td>
<td><span style="color:${c.active ? '#4ade80' : '#ef4444'}">${c.active ? 'Active' : 'Offline'}</span></td>
</tr>`).join('')
const invoiceRows = d.invoices.slice().reverse().map(inv => `
<tr>
<td>${inv.memo}</td>
<td>${inv.value.toLocaleString()} sats</td>
<td><span style="color:${inv.settled ? '#4ade80' : '#fb923c'}">${inv.settled ? 'Settled' : 'Open'}</span></td>
<td>${new Date(inv.creation_date * 1000).toLocaleString()}</td>
</tr>`).join('')
const paymentRows = d.payments.map(p => `
<tr>
<td>${p.payment_hash.slice(0,12)}...</td>
<td>${p.value_sat.toLocaleString()} sats</td>
<td>${p.fee_sat} sats</td>
<td style="color:#4ade80">Succeeded</td>
</tr>`).join('')
res.type('html').send(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ThunderHub — archy-signet</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0f0f1a;color:#e0e0e0;font-family:system-ui,-apple-system,sans-serif;padding:24px}
h1{font-size:22px;margin-bottom:4px;color:#fb923c}
.subtitle{color:rgba(255,255,255,.5);font-size:13px;margin-bottom:24px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:32px}
.stat{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-radius:12px;padding:16px}
.stat .label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:rgba(255,255,255,.4);margin-bottom:4px}
.stat .value{font-size:24px;font-weight:600}
.stat .value.green{color:#4ade80}.stat .value.orange{color:#fb923c}.stat .value.blue{color:#3b82f6}
h2{font-size:16px;margin:24px 0 12px;color:rgba(255,255,255,.8)}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.1);color:rgba(255,255,255,.4);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.5px}
td{padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.05)}
tr:hover td{background:rgba(255,255,255,.02)}
.section{background:rgba(0,0,0,.3);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:20px;margin-bottom:20px}
.badge{display:inline-block;background:rgba(247,147,26,.15);color:#fb923c;padding:2px 8px;border-radius:6px;font-size:11px;margin-left:8px}
</style></head><body>
<h1>ThunderHub<span class="badge">signet</span></h1>
<div class="subtitle">${d.info.alias} &mdash; block ${d.info.block_height.toLocaleString()} &mdash; ${d.info.num_active_channels} active channels</div>
<div class="grid">
<div class="stat"><div class="label">On-chain Balance</div><div class="value orange">${(d.balance.confirmed_balance/1e6).toFixed(2)}M sats</div></div>
<div class="stat"><div class="label">Channel Capacity</div><div class="value">${(totalCap/1e6).toFixed(1)}M sats</div></div>
<div class="stat"><div class="label">Local Balance</div><div class="value green">${(totalLocal/1e6).toFixed(1)}M sats</div></div>
<div class="stat"><div class="label">Remote Balance</div><div class="value blue">${(totalRemote/1e6).toFixed(1)}M sats</div></div>
<div class="stat"><div class="label">Routing Fees Earned</div><div class="value green">${totalFees} sats</div></div>
<div class="stat"><div class="label">Payments Sent</div><div class="value">${d.payments.length}</div></div>
</div>
<div class="section">
<h2>Channels (${d.channels.length})</h2>
<table><thead><tr><th>Peer</th><th>Capacity</th><th>Local</th><th>Remote</th><th>Status</th></tr></thead><tbody>${channelRows}</tbody></table>
</div>
<div class="section">
<h2>Recent Invoices</h2>
<table><thead><tr><th>Memo</th><th>Amount</th><th>Status</th><th>Created</th></tr></thead><tbody>${invoiceRows}</tbody></table>
</div>
<div class="section">
<h2>Recent Payments</h2>
<table><thead><tr><th>Hash</th><th>Amount</th><th>Fee</th><th>Status</th></tr></thead><tbody>${paymentRows}</tbody></table>
</div>
<div class="section">
<h2>Forwarding History</h2>
<table><thead><tr><th>In Channel</th><th>Out Channel</th><th>Amount</th><th>Fee</th><th>Time</th></tr></thead><tbody>
${d.forwarding.map(f => {
const inPeer = d.channels.find(c => c.chan_id === f.chan_id_in)?.peer_alias || f.chan_id_in
const outPeer = d.channels.find(c => c.chan_id === f.chan_id_out)?.peer_alias || f.chan_id_out
return `<tr><td>${inPeer}</td><td>${outPeer}</td><td>${f.amt_in.toLocaleString()} sats</td><td>${f.fee} sats</td><td>${new Date(f.timestamp_ns/1e6).toLocaleString()}</td></tr>`
}).join('')}
</tbody></table>
</div>
<p style="text-align:center;color:rgba(255,255,255,.25);font-size:12px;margin-top:32px">Mock ThunderHub &mdash; Archipelago Dev Mode &mdash; No Docker Required</p>
</body></html>`)
})
// ThunderHub API stubs (for any programmatic access)
app.get('/app/thunderhub/api/info', (req, res) => res.json(MOCK_LND_DATA.info))
app.get('/app/thunderhub/api/balance', (req, res) => res.json(MOCK_LND_DATA.balance))
app.get('/app/thunderhub/api/channels', (req, res) => res.json(MOCK_LND_DATA.channels))
app.get('/app/thunderhub/api/invoices', (req, res) => res.json(MOCK_LND_DATA.invoices))
app.get('/app/thunderhub/api/payments', (req, res) => res.json(MOCK_LND_DATA.payments))
app.get('/app/thunderhub/api/forwards', (req, res) => res.json(MOCK_LND_DATA.forwarding))
// Health check
app.get('/health', (req, res) => {
res.status(200).send('healthy')
@@ -2290,8 +2781,7 @@ server.listen(PORT, '0.0.0.0', async () => {
║ ║
║ Mock Password: ${MOCK_PASSWORD.padEnd(40)}
║ ║
║ Container Runtime: ${runtime.available ? `${runtime.runtime}`.padEnd(40) : '❌ Not available'.padEnd(40)}
║ Docker API: ✅ Connected ║
║ Container Runtime: ${runtime.available ? `${runtime.runtime}`.padEnd(40) : '⏭️ Simulation mode'.padEnd(40)}
║ Claude API Key: ${process.env.ANTHROPIC_API_KEY ? '✅ Set (' + process.env.ANTHROPIC_API_KEY.slice(0, 12) + '...)' : '❌ Not set (chat disabled)'.padEnd(40)}
║ ║
╚════════════════════════════════════════════════════════════╝