Skip to content

Commit ebe2490

Browse files
V48 Gate 1: Add live on-chain BTC balance to the Wallet pane
Nothing in the codebase ever fetched a chain balance - btcFeeBalance was only read from profile fields nothing writes, so the 'BTC in wallet' card showed 'Binding pending' and the nav chip rendered null as 0 BTC. - New session-scoped GET /api/wallet/btc-balance derives the bound addresses server-side from the profile wallet binding (no client-trusted address), queries mempool.space, and returns confirmed/pending sats+BTC. The ambiguous 'testnet' binding label tries testnet4 first, then testnet3, so fauceted coins surface wherever they landed. - Wallet pane 'BTC in wallet' card now shows the live balance with network and pending-confirmation detail, falling back to posture text when the source is unavailable. - Ledger documents testnet4 faucets (coinfaucet.eu, altquick.com) and the mempool.space testnet4 explorer patterns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 79127d1 commit ebe2490

4 files changed

Lines changed: 355 additions & 3 deletions

File tree

BITCODE_V48_QA.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
- Posture: interactive local experiential QA of the first live commercial (testnet) experience; app runs locally (`pnpm -C uapi dev:remote`) against the staging Supabase project; wallet network testnet4
88
- Source-safety posture: source-safe evidence only; no secrets, protected source, provider payloads, wallet material, service-role keys, database credentials, or raw private prompts are serialized here.
99

10+
## Testnet BTC resources
11+
12+
- Faucet (testnet4): https://coinfaucet.eu/en/btc-testnet4/
13+
- Faucet/exchange (testnet coins): https://altquick.com/exchange/
14+
- Explorer (testnet4): https://mempool.space/testnet4 — tx view at `…/testnet4/tx/<txid>`, address view at `…/testnet4/address/<address>`
15+
- The in-app "BTC in wallet" card reads the bound address via `/api/wallet/btc-balance` (mempool.space, testnet4-first with testnet3 fallback for the ambiguous `testnet` binding label).
16+
1017
## QA scripts (committed at `supabase/queries/v48_qa_*.sql`)
1118

1219
Run in the Supabase SQL editor; all auto-target the most recent `custom:bitcode-bitcoin` user (purge-proof, no UUID editing). Run-after map:
@@ -135,7 +142,7 @@ Track 2-4 scripts (deposit activity, BTD ledger, settlement, pack journaling) ge
135142
- [~] Profile pane readback (optional email binding) — deferred by decision 2026-06-12: wallet (plus GitHub) is the root of identity; email is optional contact only and not required for Track 1 closure. The "Add Email" repair CTA routing was fixed regardless (contract repair routes → `/packs`). The `signInWithOtp` contact-binding semantics check moves to the F4 legacy-auth eradication gate, where the email path gets disambiguated from authentication wholesale.
136143
- [x] Externals: GitHub App connect + repository inventory — verified 2026-06-12: installation `139922918` (`engineeredsoftware`, repository_selection `all`), provider readiness "succeeded" with source-safe token posture, 46 repositories synced into `vcs_repositories` with full metadata. Post-connect redirect retargeted from the legacy `/terminal` overlay to `/packs?auxillary-open-to=externals`. Connected Scope pill list was hard-capped at 8 (`repositories.slice(0, 8)`) — now renders all in a scrollable wrap.
137144
- QA evidence hygiene note: ad-hoc `user_connections` dumps that select `connection_data` wholesale expose the GitHub installation `access_token` (short-lived, 1h expiry — the one pasted on 2026-06-12 expired 21:08Z; no rotation needed). Use `v48_qa_03_user_connections_token_safe.sql` instead; never paste raw `connection_data`.
138-
- [ ] Wallet pane: binding shows verified, fauceted testnet4 balance visible
145+
- [ ] Wallet pane: fauceted testnet4 balance visible — live on-chain balance plumbing built 2026-06-12 (`/api/wallet/btc-balance` + "BTC in wallet" card; previously nothing in the codebase ever fetched a chain balance, so the card showed "Binding pending" and the nav chip rendered null as 0). Awaiting visual confirm post-faucet. Binding status stays `pending` by design until signature verification ships (checklist's earlier "verified" wording was aspirational). Nav chip wiring to the live source is a follow-up.
139146
- [ ] Organization/team + treasury panes render
140147

141148
## Track 2 — Depositing
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import { supabaseAdmin } from '@bitcode/supabase';
4+
import { createClient } from '@bitcode/supabase/ssr/server';
5+
import {
6+
bitcodeServerTelemetry,
7+
compactBitcodeServerId,
8+
} from '@/lib/bitcode-server-telemetry';
9+
10+
export const runtime = 'nodejs';
11+
12+
const SATS_PER_BTC = 100_000_000;
13+
14+
// The OAuth identity sub normalizes the wallet network to "testnet", while the
15+
// app canon is testnet4 — for the ambiguous label, try testnet4 first and fall
16+
// back to testnet3 so fauceted coins surface wherever they actually landed.
17+
const NETWORK_ENDPOINTS: Record<string, Array<{ network: string; baseUrl: string }>> = {
18+
mainnet: [{ network: 'mainnet', baseUrl: 'https://mempool.space/api' }],
19+
testnet4: [{ network: 'testnet4', baseUrl: 'https://mempool.space/testnet4/api' }],
20+
testnet: [
21+
{ network: 'testnet4', baseUrl: 'https://mempool.space/testnet4/api' },
22+
{ network: 'testnet3', baseUrl: 'https://mempool.space/testnet/api' },
23+
],
24+
signet: [{ network: 'signet', baseUrl: 'https://mempool.space/signet/api' }],
25+
};
26+
27+
function readNonEmptyString(value: unknown) {
28+
return typeof value === 'string' && value.trim() ? value.trim() : null;
29+
}
30+
31+
function asRecord(value: unknown): Record<string, unknown> | null {
32+
return value && typeof value === 'object' && !Array.isArray(value)
33+
? (value as Record<string, unknown>)
34+
: null;
35+
}
36+
37+
function isPlausibleBitcoinAddress(value: unknown) {
38+
const address = readNonEmptyString(value);
39+
if (!address) return false;
40+
41+
return (
42+
/^(bc1|tb1|bcrt1)[ac-hj-np-z02-9]{8,90}$/i.test(address) ||
43+
/^[13mn2][A-HJ-NP-Za-km-z1-9]{25,60}$/.test(address)
44+
);
45+
}
46+
47+
type AddressStats = {
48+
address: string;
49+
confirmedSats: number;
50+
pendingSats: number;
51+
};
52+
53+
async function readAddressStats(baseUrl: string, address: string): Promise<AddressStats | null> {
54+
const response = await fetch(`${baseUrl}/address/${address}`, {
55+
cache: 'no-store',
56+
headers: { accept: 'application/json' },
57+
});
58+
if (!response.ok) return null;
59+
60+
const payload = asRecord(await response.json().catch(() => null));
61+
const chain = asRecord(payload?.chain_stats);
62+
const mempool = asRecord(payload?.mempool_stats);
63+
const readSum = (stats: Record<string, unknown> | null, key: string) =>
64+
typeof stats?.[key] === 'number' ? (stats[key] as number) : 0;
65+
66+
return {
67+
address,
68+
confirmedSats: readSum(chain, 'funded_txo_sum') - readSum(chain, 'spent_txo_sum'),
69+
pendingSats: readSum(mempool, 'funded_txo_sum') - readSum(mempool, 'spent_txo_sum'),
70+
};
71+
}
72+
73+
export async function GET() {
74+
const supabase = await createClient();
75+
const {
76+
data: { user },
77+
error: userError,
78+
} = await supabase.auth.getUser();
79+
80+
if (!user || userError) {
81+
return NextResponse.json(
82+
{ error: 'A Bitcode session is required to read wallet BTC posture.', code: 'wallet_session_required' },
83+
{ status: 401 },
84+
);
85+
}
86+
87+
const { data: profile, error: profileError } = await supabaseAdmin
88+
.from('user_profiles')
89+
.select('settings')
90+
.eq('id', user.id)
91+
.maybeSingle();
92+
93+
if (profileError) {
94+
return NextResponse.json({ error: profileError.message }, { status: 500 });
95+
}
96+
97+
const binding =
98+
asRecord(asRecord(asRecord(profile?.settings)?.bitcodeProfile)?.walletBinding) ??
99+
asRecord(asRecord(profile?.settings)?.walletBinding);
100+
const addresses = [
101+
readNonEmptyString(binding?.authAddress) ?? readNonEmptyString(binding?.address),
102+
readNonEmptyString(binding?.paymentAddress),
103+
].filter((value, index, list): value is string =>
104+
Boolean(value) && isPlausibleBitcoinAddress(value) && list.indexOf(value) === index,
105+
);
106+
107+
if (!addresses.length) {
108+
return NextResponse.json(
109+
{ error: 'No wallet binding address is attached to this Bitcode profile.', code: 'wallet_binding_missing' },
110+
{ status: 422 },
111+
);
112+
}
113+
114+
const networkLabel = (readNonEmptyString(binding?.network) ?? 'testnet4').toLowerCase();
115+
const candidates = NETWORK_ENDPOINTS[networkLabel];
116+
if (!candidates) {
117+
return NextResponse.json(
118+
{ error: `Wallet network ${networkLabel} has no public balance source.`, code: 'wallet_network_unsupported' },
119+
{ status: 422 },
120+
);
121+
}
122+
123+
try {
124+
let resolved: { network: string; entries: AddressStats[] } | null = null;
125+
for (const candidate of candidates) {
126+
const entries = (
127+
await Promise.all(addresses.map((address) => readAddressStats(candidate.baseUrl, address)))
128+
).filter((entry): entry is AddressStats => entry !== null);
129+
if (!entries.length) continue;
130+
131+
const total = entries.reduce((sum, entry) => sum + entry.confirmedSats + entry.pendingSats, 0);
132+
if (!resolved) resolved = { network: candidate.network, entries };
133+
if (total > 0) {
134+
resolved = { network: candidate.network, entries };
135+
break;
136+
}
137+
}
138+
139+
if (!resolved) {
140+
return NextResponse.json(
141+
{ error: 'Balance source is unavailable.', code: 'wallet_balance_source_unavailable' },
142+
{ status: 502 },
143+
);
144+
}
145+
146+
const confirmedSats = resolved.entries.reduce((sum, entry) => sum + entry.confirmedSats, 0);
147+
const pendingSats = resolved.entries.reduce((sum, entry) => sum + entry.pendingSats, 0);
148+
149+
bitcodeServerTelemetry('info', 'wallet-btc-balance', 'read', {
150+
userId: compactBitcodeServerId(user.id),
151+
network: resolved.network,
152+
addresses: resolved.entries.map((entry) => compactBitcodeServerId(entry.address)),
153+
confirmedSats,
154+
pendingSats,
155+
});
156+
157+
return NextResponse.json({
158+
ok: true,
159+
network: resolved.network,
160+
addresses: resolved.entries.map((entry) => entry.address),
161+
confirmedSats,
162+
pendingSats,
163+
confirmedBtc: confirmedSats / SATS_PER_BTC,
164+
pendingBtc: pendingSats / SATS_PER_BTC,
165+
});
166+
} catch (error) {
167+
bitcodeServerTelemetry('warn', 'wallet-btc-balance', 'read-failed', {
168+
userId: compactBitcodeServerId(user.id),
169+
message: error instanceof Error ? error.message : String(error),
170+
});
171+
return NextResponse.json(
172+
{ error: 'Balance source is unavailable.', code: 'wallet_balance_source_unavailable' },
173+
{ status: 502 },
174+
);
175+
}
176+
}

uapi/app/auxillaries/components/AuxillariesWalletPane.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,33 @@ export default function AuxillariesWalletPane({
248248
const [selectedActivityId, setSelectedActivityId] = useState<string | null>(
249249
MASTER_MOCK_MODE ? MOCK_RUNS[0]?.id ?? null : null,
250250
);
251+
const [liveBtcBalance, setLiveBtcBalance] = useState<{
252+
confirmedBtc: number;
253+
pendingBtc: number;
254+
network: string;
255+
} | null>(null);
256+
257+
useEffect(() => {
258+
if (!walletBinding?.address) return;
259+
let cancelled = false;
260+
(async () => {
261+
try {
262+
const response = await fetch('/api/wallet/btc-balance');
263+
const payload = await response.json().catch(() => null);
264+
if (cancelled || !response.ok || !payload?.ok) return;
265+
setLiveBtcBalance({
266+
confirmedBtc: typeof payload.confirmedBtc === 'number' ? payload.confirmedBtc : 0,
267+
pendingBtc: typeof payload.pendingBtc === 'number' ? payload.pendingBtc : 0,
268+
network: typeof payload.network === 'string' ? payload.network : 'testnet4',
269+
});
270+
} catch {
271+
// Balance source unavailable: the card keeps its posture fallback.
272+
}
273+
})();
274+
return () => {
275+
cancelled = true;
276+
};
277+
}, [walletBinding?.address]);
251278

252279
useEffect(() => {
253280
if (onCompletionStatusChange && !hasCalledCompletionRef.current) {
@@ -503,8 +530,18 @@ export default function AuxillariesWalletPane({
503530
},
504531
{
505532
label: "BTC in wallet",
506-
value: formatBtcFeeBalance(supportTreasury?.btcFeeBalance ?? btcFeeBalanceSource),
507-
detail: supportTreasury?.organizationTreasurySeparated
533+
value: formatBtcFeeBalance(
534+
liveBtcBalance?.confirmedBtc ??
535+
supportTreasury?.btcFeeBalance ??
536+
btcFeeBalanceSource,
537+
),
538+
detail: liveBtcBalance
539+
? `Live on-chain ${liveBtcBalance.network} balance for the bound wallet address${
540+
liveBtcBalance.pendingBtc > 0
541+
? `; ${liveBtcBalance.pendingBtc.toLocaleString(undefined, { maximumFractionDigits: 8 })} BTC pending confirmation`
542+
: ''
543+
}.`
544+
: supportTreasury?.organizationTreasurySeparated
508545
? "Account treasury posture is source-safe and separate from organization treasury controls and Exchange market state."
509546
: hasReadableBtcFeeBalance
510547
? "Live BTC fee-liquidity posture supplied by the connected wallet posture."
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
jest.mock('@bitcode/supabase/ssr/server', () => ({
6+
createClient: jest.fn(),
7+
}));
8+
9+
jest.mock('@bitcode/supabase', () => ({
10+
supabaseAdmin: {
11+
from: jest.fn(),
12+
},
13+
}));
14+
15+
import { createClient } from '@bitcode/supabase/ssr/server';
16+
import { supabaseAdmin } from '@bitcode/supabase';
17+
import { GET } from '@/app/api/wallet/btc-balance/route';
18+
19+
const QA_ADDRESS = 'tb1p6x70u8ag7hkmgsve58lxhpgk5fhnanxp2vtuhvccv6n54f2m9mrsxe6wc2';
20+
21+
function installSupabaseMocks(options: {
22+
user?: { id: string } | null;
23+
settings?: Record<string, unknown> | null;
24+
}) {
25+
(createClient as jest.Mock).mockResolvedValue({
26+
auth: {
27+
getUser: jest.fn().mockResolvedValue({
28+
data: { user: Object.prototype.hasOwnProperty.call(options, 'user') ? options.user : { id: 'user-1' } },
29+
error: null,
30+
}),
31+
},
32+
});
33+
34+
const builder = {
35+
select: jest.fn(),
36+
eq: jest.fn(),
37+
maybeSingle: jest.fn().mockResolvedValue({
38+
data: options.settings === null ? null : { settings: options.settings },
39+
error: null,
40+
}),
41+
};
42+
builder.select.mockReturnValue(builder);
43+
builder.eq.mockReturnValue(builder);
44+
(supabaseAdmin.from as jest.Mock).mockReturnValue(builder);
45+
}
46+
47+
function mempoolResponse(confirmed: number, pending = 0) {
48+
return {
49+
ok: true,
50+
json: async () => ({
51+
chain_stats: { funded_txo_sum: confirmed, spent_txo_sum: 0 },
52+
mempool_stats: { funded_txo_sum: pending, spent_txo_sum: 0 },
53+
}),
54+
};
55+
}
56+
57+
describe('GET /api/wallet/btc-balance', () => {
58+
beforeEach(() => {
59+
jest.clearAllMocks();
60+
});
61+
62+
it('requires a session', async () => {
63+
installSupabaseMocks({ user: null, settings: null });
64+
65+
const response = await GET();
66+
expect(response.status).toBe(401);
67+
await expect(response.json()).resolves.toEqual(
68+
expect.objectContaining({ code: 'wallet_session_required' }),
69+
);
70+
});
71+
72+
it('rejects profiles with no wallet binding address', async () => {
73+
installSupabaseMocks({ settings: { bitcodeProfile: { walletBinding: null } } });
74+
75+
const response = await GET();
76+
expect(response.status).toBe(422);
77+
await expect(response.json()).resolves.toEqual(
78+
expect.objectContaining({ code: 'wallet_binding_missing' }),
79+
);
80+
});
81+
82+
it('reads the bound address balance, trying testnet4 first for the ambiguous testnet label', async () => {
83+
installSupabaseMocks({
84+
settings: {
85+
bitcodeProfile: {
86+
walletBinding: {
87+
address: QA_ADDRESS,
88+
authAddress: QA_ADDRESS,
89+
paymentAddress: null,
90+
network: 'testnet',
91+
},
92+
},
93+
},
94+
});
95+
global.fetch = jest.fn(async () => mempoolResponse(125_000, 4_000)) as jest.Mock;
96+
97+
const response = await GET();
98+
expect(response.status).toBe(200);
99+
await expect(response.json()).resolves.toEqual(
100+
expect.objectContaining({
101+
ok: true,
102+
network: 'testnet4',
103+
confirmedSats: 125_000,
104+
pendingSats: 4_000,
105+
confirmedBtc: 0.00125,
106+
}),
107+
);
108+
expect(global.fetch).toHaveBeenCalledWith(
109+
`https://mempool.space/testnet4/api/address/${QA_ADDRESS}`,
110+
expect.objectContaining({ cache: 'no-store' }),
111+
);
112+
});
113+
114+
it('falls back to testnet3 when testnet4 shows no funds', async () => {
115+
installSupabaseMocks({
116+
settings: {
117+
bitcodeProfile: {
118+
walletBinding: { address: QA_ADDRESS, network: 'testnet' },
119+
},
120+
},
121+
});
122+
global.fetch = jest.fn(async (url: string) =>
123+
url.includes('/testnet4/') ? mempoolResponse(0, 0) : mempoolResponse(99_000, 0),
124+
) as jest.Mock;
125+
126+
const response = await GET();
127+
expect(response.status).toBe(200);
128+
await expect(response.json()).resolves.toEqual(
129+
expect.objectContaining({ ok: true, network: 'testnet3', confirmedSats: 99_000 }),
130+
);
131+
});
132+
});

0 commit comments

Comments
 (0)