Skip to content

Commit f56bd89

Browse files
wip v28
1 parent 999550f commit f56bd89

8 files changed

Lines changed: 260 additions & 22 deletions

File tree

BITCODE_SPEC_V28_NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ May 14 live deployment QA proved the custom Bitcoin OAuth path through Supabase
7272

7373
Fresh-nuke onboarding then exposed a timing gap: Supabase Auth and the empty trigger-created profile existed immediately, while Bitcode's wallet projection appeared only after the app remounted the Wallet pane and replayed the locally signed proof. V28 now treats that gap as unacceptable for first-run readiness. A route-global wallet session persistence bridge must replay any locally signed Bitcoin wallet proof once a Supabase session exists, so callback-to-root, hard refresh, or Terminal landing all converge on the same `user_profiles.settings.bitcodeProfile.walletBinding` and `user_connections` projection before GitHub onboarding proceeds.
7474

75+
Live staging OAuth also exposed two callback hardening requirements. First, the custom wallet authorize page must not rely on a single provider scan at first render because browser wallet extensions can inject provider globals after the route has already hydrated; `/tps/wallet/authorize` must retry discovery, expose an explicit rescan, and preserve the provider hint fallback so Leather/Xverse can still open even when the first scan is empty. Second, the Supabase callback exchange must be idempotent: if the first exchange succeeds and hydration recovery or remount tries to exchange the same code again after the PKCE verifier was consumed, the callback must prefer the already-established session over redirecting to a false login error. Staging URL configuration must also allow both `https://bitcode.exchange` and `https://www.bitcode.exchange` callback routes until a single canonical host redirect is enforced.
76+
7577
### Deterministic Model Boundary
7678

7779
V28 must remove the idea that a user can choose ledgerized Bitcode synthesis models.

BITCODE_V28_QA.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Exchange and the website Conversations interface are no longer V28 QA surfaces.
1717
| Latest resume | 2026-05-13 |
1818
| Mock app URL | `http://127.0.0.1:3000` |
1919
| Testnet-readiness app URL | `http://127.0.0.1:3001` when a second dev server is running |
20+
| Live staging URL | `https://bitcode.exchange`; current QA may also enter through `https://www.bitcode.exchange` while host canonicalization is being settled |
2021
| Server mode | manual QA now prioritizes real staging-testnet with mocks off; mock mode remains a deterministic regression harness that must stay synchronized |
2122
| Active canon pointer | `BITCODE_SPEC.txt` -> `V27` |
2223
| Draft target | `V28` |
@@ -158,6 +159,19 @@ For every staging-testnet pass, paste back:
158159
6. Supabase SQL results from the queries below.
159160
7. A short classification for each issue: V28 blocker, V29 Terminal depth, V31 Auxillaries depth, V32 provation/testing, V33 interfaces, V34 deployment, V35 telemetry/docs, or V36+ Exchange/Conversations.
160161

162+
### Live Staging Host And Callback Checks
163+
164+
For wallet onboarding on the deployed staging-testnet host, Supabase Auth must accept both hostnames until Bitcode enforces one canonical host:
165+
166+
- `https://bitcode.exchange/tps/supabase/callback`
167+
- `https://www.bitcode.exchange/tps/supabase/callback`
168+
- `https://bitcode.exchange/auxillaries/wallet`
169+
- `https://www.bitcode.exchange/auxillaries/wallet`
170+
171+
When Wallet opens `/tps/wallet/authorize`, the page should show Leather and Xverse if both extensions are installed and unlocked. If it initially says no provider was detected, wait a few seconds or click `Rescan wallets`; provider injection after hydration is a V28-observed browser-extension timing behavior. The fallback button may still open the hinted provider, but the expected MVP state is explicit Leather/Xverse choices plus a working fallback.
172+
173+
After signing, a transient root URL containing `loginError=server_error` and `both auth code and code verifier should be non-empty` is a callback idempotency failure if the session nevertheless exists. V28 expects the callback to treat an already-established Supabase session as success and continue to Wallet without showing a false login error.
174+
161175
### Supabase SQL Checks
162176

163177
Run these in the staging Supabase SQL editor after each milestone and paste the result rows or any table/permission errors.

supabase/config.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ site_url = "https://bitcode.exchange"
4646
additional_redirect_urls = [
4747
"https://bitcode.exchange/tps/supabase/callback",
4848
"https://bitcode.exchange/auxillaries/wallet",
49+
"https://www.bitcode.exchange/tps/supabase/callback",
50+
"https://www.bitcode.exchange/auxillaries/wallet",
4951
"http://localhost:3000/tps/supabase/callback",
5052
"http://localhost:3001/tps/supabase/callback",
5153
]

uapi/app/login/callback/LoginCallbackClient.tsx

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ export default function LoginCallbackClient({
2121
codeKind = 'none',
2222
nextPath = '/',
2323
}: LoginCallbackClientProps) {
24+
const [mounted, setMounted] = useState(false);
2425
const [copied, setCopied] = useState(false);
2526
const [redirecting, setRedirecting] = useState(false);
2627
const containerRef = useRef<HTMLDivElement>(null);
28+
const callbackStartedRef = useRef(false);
2729

2830
// Determine if this is an OTP flow (magic-link) or an OAuth redirect
2931
const isOtpFlow = codeKind === 'token_hash' && Boolean(code && code.trim().length > 0);
@@ -33,6 +35,11 @@ export default function LoginCallbackClient({
3335
const my = useMotionValue(0);
3436
const mxPx = useMotionTemplate`${mx}px`;
3537
const myPx = useMotionTemplate`${my}px`;
38+
39+
useEffect(() => {
40+
setMounted(true);
41+
}, []);
42+
3643
useEffect(() => {
3744
const node = containerRef.current;
3845
if (!node) return;
@@ -91,6 +98,9 @@ export default function LoginCallbackClient({
9198
* are never stranded on this screen.
9299
*/
93100
useEffect(() => {
101+
if (callbackStartedRef.current) return;
102+
callbackStartedRef.current = true;
103+
94104
let cleanup: (() => void) | undefined;
95105

96106
(async () => {
@@ -116,21 +126,61 @@ export default function LoginCallbackClient({
116126
}, 600);
117127
};
118128

129+
const callbackKey =
130+
codeKind === 'oauth_code' && code
131+
? `bitcode.supabase.callback.exchanged.${code.slice(0, 80)}`
132+
: null;
133+
134+
const completeIfSessionExists = async () => {
135+
let session;
136+
try {
137+
const { data } = await supabase.auth.getSession();
138+
session = data.session;
139+
} catch {
140+
session = null;
141+
}
142+
if (!session) return false;
143+
if (callbackKey) {
144+
try {
145+
window.sessionStorage.setItem(callbackKey, '1');
146+
} catch {}
147+
}
148+
complete();
149+
return true;
150+
};
151+
119152
// 1️⃣ First, attempt to capture the session from the URL fragment –
120153
// this is required for implicit/#access_token flows used by Google.
121154
// 1️⃣ Attempt to extract tokens from the URL hash (implicit flow
122155
// `#access_token=…`). Supabase doesn’t parse the hash automatically in
123156
// the browser SDK, so we do it manually and call `setSession()`.
124157
const hash = typeof window !== 'undefined' ? window.location.hash || '' : '';
125158
if (codeKind === 'oauth_code' && code) {
159+
if (callbackKey) {
160+
try {
161+
if (window.sessionStorage.getItem(callbackKey) === '1' && await completeIfSessionExists()) {
162+
return;
163+
}
164+
} catch {}
165+
}
166+
126167
try {
127168
const { error } = await supabase.auth.exchangeCodeForSession(code);
128169
if (error) throw error;
170+
if (callbackKey) {
171+
try {
172+
window.sessionStorage.setItem(callbackKey, '1');
173+
} catch {}
174+
}
129175
complete();
130176
return;
131177
} catch (exchangeError) {
178+
if (await completeIfSessionExists()) return;
179+
await new Promise((resolve) => setTimeout(resolve, 300));
180+
if (await completeIfSessionExists()) return;
181+
132182
const message = exchangeError instanceof Error ? exchangeError.message : String(exchangeError);
133-
window.location.href = `/?loginError=server_error&loginErrorDescription=${encodeURIComponent(message)}`;
183+
window.location.replace(`/?loginError=server_error&loginErrorDescription=${encodeURIComponent(message)}`);
134184
return;
135185
}
136186
}
@@ -192,6 +242,21 @@ export default function LoginCallbackClient({
192242
return () => window.removeEventListener('keydown', escHandler);
193243
}, [nextPath]);
194244

245+
if (!mounted) {
246+
return (
247+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black text-white">
248+
<div className="text-center">
249+
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-gray-400">
250+
Bitcode authentication
251+
</p>
252+
<h1 className="mt-4 text-4xl font-light text-[rgba(103,254,183,0.9)]">
253+
Verifying…
254+
</h1>
255+
</div>
256+
</div>
257+
);
258+
}
259+
195260
return (
196261
<motion.div
197262
ref={containerRef}

uapi/app/tps/wallet/authorize/BitcoinWalletAuthorizeClient.tsx

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

3-
import { useEffect, useMemo, useState } from 'react';
3+
import React from 'react';
4+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
45

56
import {
67
connectBitcoinWallet,
@@ -23,6 +24,13 @@ type BitcoinWalletAuthorizeClientProps = {
2324
};
2425

2526
const supportedHints = new Set(['xverse', 'leather', 'unisat', 'okx-bitcoin']);
27+
const providerLabels: Record<BitcoinWalletProviderId, string> = {
28+
xverse: 'Xverse',
29+
leather: 'Leather',
30+
unisat: 'UniSat',
31+
'okx-bitcoin': 'OKX Bitcoin',
32+
};
33+
const providerScanRetryDelays = [0, 250, 1000, 2500] as const;
2634

2735
function normalizeProviderHint(value: string): BitcoinWalletProviderId | undefined {
2836
const normalized = value.trim().toLowerCase();
@@ -50,32 +58,51 @@ export default function BitcoinWalletAuthorizeClient({
5058
const [scanState, setScanState] = useState<'checking' | 'ready' | 'none'>('checking');
5159
const [status, setStatus] = useState<'idle' | 'requesting' | 'redirecting'>('idle');
5260
const [error, setError] = useState<string | null>(null);
61+
const mountedRef = useRef(true);
62+
const latestProvidersRef = useRef<BitcoinWalletProviderSummary[]>([]);
5363

5464
const preferredProvider = useMemo(() => normalizeProviderHint(walletProviderHint), [walletProviderHint]);
5565
const hasRequiredParams = Boolean(clientId && redirectUri && (!responseType || responseType === 'code'));
5666

57-
useEffect(() => {
58-
let cancelled = false;
59-
inspectBitcoinWalletProviders()
60-
.then((detected) => {
61-
if (cancelled) return;
62-
setProviders(detected);
63-
setScanState(detected.length > 0 ? 'ready' : 'none');
64-
bitcodeQaTelemetry('info', 'wallet-oauth', 'provider-scan', detected);
65-
})
66-
.catch((scanError) => {
67-
if (cancelled) return;
68-
setProviders([]);
69-
setScanState('none');
70-
bitcodeQaTelemetry('warn', 'wallet-oauth', 'provider-scan-failed', {
71-
message: scanError instanceof Error ? scanError.message : 'unknown',
72-
});
67+
const scanProviders = useCallback(async (reason: string) => {
68+
if (reason === 'manual' || latestProvidersRef.current.length === 0) {
69+
setScanState('checking');
70+
}
71+
try {
72+
const detected = await inspectBitcoinWalletProviders();
73+
if (!mountedRef.current) return;
74+
const nextProviders = detected.length > 0 ? detected : latestProvidersRef.current;
75+
latestProvidersRef.current = nextProviders;
76+
setProviders(nextProviders);
77+
setScanState(nextProviders.length > 0 ? 'ready' : 'none');
78+
bitcodeQaTelemetry('info', 'wallet-oauth', 'provider-scan', {
79+
reason,
80+
detected,
81+
retained: detected.length === 0 && nextProviders.length > 0,
82+
});
83+
} catch (scanError) {
84+
if (!mountedRef.current) return;
85+
setProviders(latestProvidersRef.current);
86+
setScanState(latestProvidersRef.current.length > 0 ? 'ready' : 'none');
87+
bitcodeQaTelemetry('warn', 'wallet-oauth', 'provider-scan-failed', {
88+
reason,
89+
message: scanError instanceof Error ? scanError.message : 'unknown',
7390
});
91+
}
92+
}, []);
7493

94+
useEffect(() => {
95+
mountedRef.current = true;
96+
const timers = providerScanRetryDelays.map((delay) =>
97+
window.setTimeout(() => {
98+
void scanProviders(delay === 0 ? 'mount' : `retry-${delay}ms`);
99+
}, delay),
100+
);
75101
return () => {
76-
cancelled = true;
102+
mountedRef.current = false;
103+
timers.forEach((timer) => window.clearTimeout(timer));
77104
};
78-
}, []);
105+
}, [scanProviders]);
79106

80107
const authorize = async (providerId?: BitcoinWalletProviderId) => {
81108
setError(null);
@@ -154,6 +181,8 @@ export default function BitcoinWalletAuthorizeClient({
154181
return [preferred, ...providers.filter((provider) => provider.id !== preferredProvider)];
155182
}, [preferredProvider, providers]);
156183

184+
const fallbackProviderLabel = preferredProvider ? providerLabels[preferredProvider] : 'Bitcoin wallet';
185+
157186
return (
158187
<main className="min-h-screen bg-[#050912] text-white">
159188
<section className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center px-6 py-12">
@@ -208,9 +237,19 @@ export default function BitcoinWalletAuthorizeClient({
208237
disabled={status !== 'idle'}
209238
className="inline-flex items-center justify-center rounded-full border border-orange-300/34 bg-orange-400/14 px-5 py-2.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-orange-50 transition hover:border-orange-300/54 hover:bg-orange-400/22 disabled:cursor-wait disabled:opacity-60"
210239
>
211-
{status === 'requesting' ? 'Opening Bitcoin wallet' : 'Continue with Bitcoin wallet'}
240+
{status === 'requesting'
241+
? `Opening ${fallbackProviderLabel}`
242+
: `Continue with ${fallbackProviderLabel}`}
212243
</button>
213244
)}
245+
<button
246+
type="button"
247+
onClick={() => scanProviders('manual')}
248+
disabled={status !== 'idle' || scanState === 'checking'}
249+
className="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/6 px-4 py-2.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/66 transition hover:border-white/24 hover:bg-white/10 disabled:cursor-wait disabled:opacity-45"
250+
>
251+
{scanState === 'checking' ? 'Scanning wallets' : 'Rescan wallets'}
252+
</button>
214253
</div>
215254

216255
{error ? (
@@ -228,4 +267,3 @@ export default function BitcoinWalletAuthorizeClient({
228267
</main>
229268
);
230269
}
231-

uapi/jest.config.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ module.exports = {
134134
'<rootDir>/tests/walletPane.initialFlow.test.tsx',
135135
'<rootDir>/tests/walletPane.test.tsx',
136136
'<rootDir>/tests/walletSessionPersistenceBridge.test.tsx',
137+
'<rootDir>/tests/bitcoinWalletAuthorizeClient.test.tsx',
137138
'<rootDir>/tests/externalsPane.dataShareFlow.test.tsx',
138139
'<rootDir>/tests/walletPane.integration.test.tsx',
139140
'<rootDir>/tests/navPublicShell.test.tsx',

uapi/lib/bitcoin-wallet-client.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,22 @@ function readWindowRecord() {
203203
return window as typeof window & UnknownRecord;
204204
}
205205

206+
function readProviderRegistryCandidates(value: unknown): UnknownRecord[] {
207+
if (Array.isArray(value)) {
208+
return value.flatMap((entry) => {
209+
const record = asRecord(entry);
210+
return record ? [record] : [];
211+
});
212+
}
213+
214+
const record = asRecord(value);
215+
if (!record) return [];
216+
return Object.values(record).flatMap((entry) => {
217+
const candidate = asRecord(entry);
218+
return candidate ? [candidate] : [];
219+
});
220+
}
221+
206222
function detectDirectBitcoinWalletProviders(): BitcoinWalletProvider[] {
207223
const windowRecord = readWindowRecord();
208224
if (!windowRecord) return [];
@@ -279,6 +295,27 @@ async function detectXverseProvider(): Promise<Extract<BitcoinWalletProvider, {
279295
}
280296

281297
const windowRecord = readWindowRecord();
298+
const registryCandidates = [
299+
...readProviderRegistryCandidates(windowRecord?.btc_providers),
300+
...readProviderRegistryCandidates(windowRecord?.webbtc_providers),
301+
];
302+
const registryProvider = registryCandidates.find((candidate) => {
303+
const id = readString(candidate.id) ?? readString(candidate.providerId) ?? '';
304+
const name = readString(candidate.name) ?? readString(candidate.label) ?? '';
305+
return /xverse/i.test(id) || /xverse/i.test(name) || id === 'BitcoinProvider';
306+
});
307+
if (registryProvider) {
308+
return {
309+
id: 'xverse',
310+
label: 'Xverse',
311+
providerId:
312+
readString(registryProvider.id) ??
313+
readString(registryProvider.providerId) ??
314+
'BitcoinProvider',
315+
request: bindFunction(registryProvider, registryProvider.request),
316+
};
317+
}
318+
282319
const xverseProvider = asRecord(asRecord(windowRecord?.XverseProviders)?.BitcoinProvider);
283320
if (xverseProvider) {
284321
return {

0 commit comments

Comments
 (0)