Skip to content

Commit 761c00e

Browse files
authored
Merge pull request #407 from Chia-Network/testnet_toggle
Add a toggle on WalletConnection screen for testnet or mainnet
2 parents 7aa5fa4 + 6907fab commit 761c00e

34 files changed

Lines changed: 752 additions & 83 deletions

CONNECTIVITY.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,12 +372,18 @@ The hub does not create a session. It can only advise and relay:
372372
it **ignores** the advisory (no `session_reject`).
373373
4. If the target consents, it marks itself busy, generates a random hex
374374
`game_session_id`, sends a bencodex `session_proposal` app message (including
375-
the `game_session_id`) to the challenger through the addressed relay, starts
375+
the `game_session_id` and its selected `network`) to the challenger through
376+
the addressed relay, starts
376377
WASM as initiator, and sends binary handshake frames. Persist of that start
377378
is async; `session_reject` / local cancel must abort any in-flight start so
378379
it cannot resurrect an orphan handshake.
379380
5. The challenger app checks local availability before showing the proposal
380-
prompt. While the prompt is open it reserves that peer id so early
381+
prompt. The hub is network-blind, so cross-network challenges can reach this
382+
point; the challenger rejects a proposal whose `network` is missing,
383+
malformed, or not equal to its own network preference with `session_reject`
384+
and no consent UI (a mainnet and a testnet client bind different genesis
385+
challenges, so a session between them could never verify signatures). While
386+
the prompt is open it reserves that peer id so early
381387
`HandshakeA` bytes can buffer. If unavailable or declined, it sends
382388
`session_reject` and discards buffered handshake bytes. Receiving
383389
`session_reject` during pre-active matchmaking cancels the attempt
@@ -428,7 +434,10 @@ The hub does not create a session. It can only advise and relay:
428434
(`shouldReportHubBusy(phase, false)` forces busy regardless of phase) and
429435
cancels any pending pre-Active matchmaking attempt; wallet reconnect
430436
recomputes presence from the session phase and any in-progress non-terminal
431-
restore cradle (phase alone is often still `none` mid-resume). (`Shell.tsx`)
437+
restore cradle (phase alone is often still `none` mid-resume). The Choose
438+
Connection network toggle is locked while a session binds a genesis
439+
challenge (`sessionLocksNetwork`) so reconnect cannot pair a different
440+
chain id than the existing WASM cradle. (`Shell.tsx`)
432441

433442
- **Session state surfaced to Shell**: `GameSession` reports coarse session
434443
phase (`off-chain | on-chain | resolved`) and an error flag to Shell via

FRONTEND_ARCHITECTURE.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,17 +202,23 @@ while WASM protocol frames remain raw bytes with the existing reliability tags.
202202
`session_reject`).
203203
6. If the accepter consents, that app becomes the channel initiator. It marks
204204
itself busy, generates a random hex `game_session_id`, sends a bencodex
205-
`session_proposal` app message (including the `game_session_id`) to the peer,
205+
`session_proposal` app message (including the `game_session_id` and its
206+
selected `network`) to the peer,
206207
starts the WASM session as initiator, and then sends the binary handshake
207208
frames through the same addressed pipe. Starting persists session state
208209
asynchronously; a later `session_reject` (or local cancel) must abort that
209210
in-flight start so it cannot resurrect an orphan handshake.
210211
7. The peer receives the `session_proposal`, checks local availability and
211-
validates amounts/timeouts at the trust boundary, stores the
212+
validates amounts/timeouts and `network` at the trust boundary, stores the
212213
`game_session_id` in its PeerSession, reserves that peer id so early
213214
handshake bytes can buffer, and shows its own consent prompt. Invalid
214-
amounts or out-of-range timeouts are rejected with `session_reject` only —
215-
they must not clear a finished freeze or IndexedDB checkpoint. If unavailable
215+
amounts, out-of-range timeouts, or a `network` that is missing, malformed, or
216+
not equal to the local network preference are rejected with `session_reject`
217+
only —
218+
they must not clear a finished freeze or IndexedDB checkpoint. The hub is
219+
network-blind, so a cross-network match reaches this check and is rejected
220+
here before any consent UI (differing genesis challenges would break every
221+
signature). If unavailable
216222
or declined, it sends `session_reject` and discards any buffered handshake
217223
bytes. Receiving `session_reject` or `delivery_failure` during an Accept
218224
transition aborts that attempt with the same freeze-safe disposition as

front-end/src/components/AmountInput.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useCallback, useRef, useEffect } from 'react';
2+
import { getCurrencyLabels } from '../constants/currency';
23

34
function mojosToXchStr(mojos: bigint): string {
45
const s = mojos.toString().padStart(13, '0');
@@ -57,6 +58,7 @@ export function AmountInput({
5758
exceedsLabel = 'Exceeds available balance.',
5859
onKeyDown,
5960
}: AmountInputProps) {
61+
const labels = getCurrencyLabels();
6062
const [unit, setUnit] = useState<AmountUnit>('mojo');
6163
const [rawInput, setRawInput] = useState(() => valueMojos.toString());
6264
const lastExternalMojos = useRef(valueMojos);
@@ -119,14 +121,14 @@ export function AmountInput({
119121
onClick={() => handleUnitChange('mojo')}
120122
className={`px-2 py-0.5 transition-colors ${unit === 'mojo' ? 'bg-canvas-bg-active font-semibold' : 'hover:bg-canvas-bg-hover'}`}
121123
>
122-
mojo
124+
{labels.mojo}
123125
</button>
124126
<button
125127
type="button"
126128
onClick={() => handleUnitChange('xch')}
127129
className={`px-2 py-0.5 transition-colors border-l border-canvas-border ${unit === 'xch' ? 'bg-canvas-bg-active font-semibold' : 'hover:bg-canvas-bg-hover'}`}
128130
>
129-
XCH
131+
{labels.xch}
130132
</button>
131133
</div>
132134
</div>
@@ -151,7 +153,9 @@ export function AmountInput({
151153
className="underline font-medium hover:text-alert-text-contrast transition-colors"
152154
>
153155
Use max (
154-
{unit === 'xch' ? `${mojosToXchStr(maxMojos!)} XCH` : `${maxMojos!.toString()} mojos`}
156+
{unit === 'xch'
157+
? `${mojosToXchStr(maxMojos!)} ${labels.xch}`
158+
: `${maxMojos!.toString()} ${labels.mojos}`}
155159
)
156160
</button>
157161
)}

front-end/src/components/GameProposalDialogs.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { isValidKrunkStake } from '../features/krunk/adapter';
33
import { gameDisplayName, REGISTERED_GAMES } from '../lib/gameRegistry';
44
import { composeDraftCanSubmit, composeDraftTerms } from '../lib/session/model';
55
import { formatMojos } from '../util';
6+
import { getCurrencyLabels } from '../constants/currency';
67
import { AmountInput } from './AmountInput';
78
import { Button } from './button';
89

@@ -118,7 +119,9 @@ export function ComposeProposalDialog({
118119
/>
119120
)}
120121
{isKrunk && perHandAmount > 0n && !krunkStakeValid && (
121-
<p className="text-xs text-alert-text">Krunk stakes must be multiples of 100 mojos.</p>
122+
<p className="text-xs text-alert-text">
123+
Krunk stakes must be multiples of 100 {getCurrencyLabels().mojos}.
124+
</p>
122125
)}
123126
<div className="flex w-full flex-col items-center gap-1">
124127
<label className="text-xs font-medium text-canvas-text">Timeout (blocks)</label>

front-end/src/components/Shell.tsx

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ import {
6262
setDefaultFee as saveDefaultFee,
6363
getFeeUnit,
6464
setFeeUnit as saveFeeUnit,
65+
getNetwork,
66+
setNetwork as saveNetwork,
6567
getActiveTab as getSavedTab,
6668
setActiveTab as saveActiveTab,
6769
getUnreadGame as getSavedUnreadGame,
@@ -79,6 +81,8 @@ import {
7981
peekAlias,
8082
setAlias,
8183
} from '../hooks/save';
84+
import type { ChiaNetwork } from '../lib/session/saveEnvelope';
85+
import { getCurrencyLabels } from '../constants/currency';
8286
import {
8387
sessionController,
8488
destroySessionController,
@@ -100,6 +104,7 @@ import {
100104
isAvailableForNewSessionPrompt as checkAvailableForNewSessionPrompt,
101105
isRestoreBlocked,
102106
restoreGateAfterTerminalFinalization,
107+
sessionLocksNetwork,
103108
shouldCancelAttemptOnDisconnect,
104109
shouldCancelOnPeerUnreachable,
105110
shouldMountGameSession,
@@ -132,6 +137,7 @@ import {
132137
isValidTimeoutString,
133138
parseOptionalBigInt,
134139
parseSessionAmount,
140+
sessionProposalNetworkMatches,
135141
} from '../lib/session/peerSessionParams';
136142
import { sessionModelForReactProps } from '../lib/session/finishedSessionDisplay';
137143
import { finalizeTerminalSession } from '../lib/session/terminalFinalization';
@@ -245,6 +251,7 @@ function SessionBuyIn({
245251
channelTimeout?: string;
246252
unrollTimeout?: string;
247253
}) {
254+
const labels = getCurrencyLabels();
248255
const effectiveChannelTimeout =
249256
parseOptionalBigInt(channelTimeout) ?? DEFAULT_CHANNEL_TIMEOUT_BLOCKS;
250257
const effectiveUnrollTimeout =
@@ -253,7 +260,7 @@ function SessionBuyIn({
253260
return (
254261
<>
255262
<br />
256-
Buy-in: <strong>{myAmount}</strong> mojos
263+
Buy-in: <strong>{myAmount}</strong> {labels.mojos}
257264
<br />
258265
Channel timeout: <strong>{effectiveChannelTimeout.toString()}</strong> blocks
259266
<br />
@@ -265,9 +272,9 @@ function SessionBuyIn({
265272
return (
266273
<>
267274
<br />
268-
Your buy-in: <strong>{myAmount}</strong> mojos
275+
Your buy-in: <strong>{myAmount}</strong> {labels.mojos}
269276
<br />
270-
Their buy-in: <strong>{theirAmount}</strong> mojos
277+
Their buy-in: <strong>{theirAmount}</strong> {labels.mojos}
271278
<br />
272279
Channel timeout: <strong>{effectiveChannelTimeout.toString()}</strong> blocks
273280
<br />
@@ -1055,6 +1062,7 @@ const Shell = () => {
10551062
const wcAbortRef = useRef(false);
10561063
const [defaultFee, setDefaultFee] = useState<bigint>(() => getDefaultFee());
10571064
const [feeUnit, setFeeUnit] = useState<'mojo' | 'xch'>(() => getFeeUnit());
1065+
const [network, setNetwork] = useState<ChiaNetwork>(() => getNetwork());
10581066
const [feeEditing, setFeeEditing] = useState(false);
10591067
const [feeInput, setFeeInput] = useState('');
10601068
const feeInputRef = useRef<HTMLInputElement>(null);
@@ -1133,6 +1141,8 @@ const Shell = () => {
11331141
[feeEditing, feeInput, parseFeeInput],
11341142
);
11351143

1144+
const currency = getCurrencyLabels();
1145+
11361146
// Theme state
11371147
const [isDark, setIsDark] = useState<boolean>(() => {
11381148
const stored = getTheme();
@@ -1166,6 +1176,26 @@ const Shell = () => {
11661176
const sessionPhaseRef = useRef<SessionPhase>('none');
11671177
sessionPhaseRef.current = sessionPhase;
11681178

1179+
const networkLocked = sessionLocksNetwork(
1180+
sessionPhase,
1181+
sessionSaveRef.current?.phase,
1182+
sessionConfig?.pairingToken,
1183+
);
1184+
1185+
const handleNetworkChange = useCallback((next: ChiaNetwork) => {
1186+
if (
1187+
sessionLocksNetwork(
1188+
sessionPhaseRef.current,
1189+
sessionSaveRef.current?.phase,
1190+
sessionConfigRef.current?.pairingToken,
1191+
)
1192+
) {
1193+
return;
1194+
}
1195+
setNetwork(next);
1196+
saveNetwork(next);
1197+
}, []);
1198+
11691199
const deferStateUpdate = useCallback((fn: () => void) => {
11701200
if (typeof queueMicrotask === 'function') {
11711201
queueMicrotask(fn);
@@ -1576,6 +1606,7 @@ const Shell = () => {
15761606
channel_timeout: advisory.channel_timeout,
15771607
unroll_timeout: advisory.unroll_timeout,
15781608
game_session_id: gameSessionId,
1609+
network: getNetwork(),
15791610
});
15801611
await startFreshSessionWithPeer({
15811612
peerId: advisory.peer_id,
@@ -1881,6 +1912,13 @@ const Shell = () => {
18811912
sendSessionReject(fromId);
18821913
return;
18831914
}
1915+
if (!sessionProposalNetworkMatches(msg.network, getNetwork())) {
1916+
log(
1917+
`[Shell] session_reject to=${fromId}: proposal network mismatch theirs=${msg.network ?? 'none'} mine=${getNetwork()}`,
1918+
);
1919+
sendSessionReject(fromId);
1920+
return;
1921+
}
18841922
const proposalSessionId = msg.game_session_id ?? generateSessionId();
18851923
peerSessionRef.current?.destroy();
18861924
peerSessionRef.current = new PeerSession(fromId, proposalSessionId, conn);
@@ -3720,7 +3758,7 @@ const Shell = () => {
37203758
</div>
37213759
{balance !== undefined && (
37223760
<p className="text-2xl font-bold text-canvas-text-contrast">
3723-
{balance.toLocaleString()} mojos
3761+
{balance.toLocaleString()} {currency.mojos}
37243762
</p>
37253763
)}
37263764
<div className="w-full max-w-xs text-sm text-canvas-text">
@@ -3731,13 +3769,13 @@ const Shell = () => {
37313769
onClick={() => handleFeeUnitChange('mojo')}
37323770
className={`px-2 py-0.5 transition-colors ${feeUnit === 'mojo' ? 'bg-canvas-bg-active font-semibold' : 'hover:bg-canvas-bg-hover'}`}
37333771
>
3734-
mojo
3772+
{currency.mojo}
37353773
</button>
37363774
<button
37373775
onClick={() => handleFeeUnitChange('xch')}
37383776
className={`px-2 py-0.5 transition-colors border-l border-canvas-border ${feeUnit === 'xch' ? 'bg-canvas-bg-active font-semibold' : 'hover:bg-canvas-bg-hover'}`}
37393777
>
3740-
XCH
3778+
{currency.xch}
37413779
</button>
37423780
</div>
37433781
</div>
@@ -3774,7 +3812,7 @@ const Shell = () => {
37743812
onClick={startEditingFee}
37753813
className="w-full text-left px-3 py-2 rounded-md bg-canvas-bg-subtle text-canvas-text border border-canvas-border hover:bg-canvas-bg-hover transition-colors cursor-pointer"
37763814
>
3777-
{feeDisplayText()} {feeUnit === 'xch' ? 'XCH' : 'mojos'}
3815+
{feeDisplayText()} {feeUnit === 'xch' ? currency.xch : currency.mojos}
37783816
</button>
37793817
)}
37803818
</div>
@@ -3851,13 +3889,13 @@ const Shell = () => {
38513889
onClick={() => handleFeeUnitChange('mojo')}
38523890
className={`px-2 py-0.5 transition-colors ${feeUnit === 'mojo' ? 'bg-canvas-bg-active font-semibold' : 'hover:bg-canvas-bg-hover'}`}
38533891
>
3854-
mojo
3892+
{currency.mojo}
38553893
</button>
38563894
<button
38573895
onClick={() => handleFeeUnitChange('xch')}
38583896
className={`px-2 py-0.5 transition-colors border-l border-canvas-border ${feeUnit === 'xch' ? 'bg-canvas-bg-active font-semibold' : 'hover:bg-canvas-bg-hover'}`}
38593897
>
3860-
XCH
3898+
{currency.xch}
38613899
</button>
38623900
</div>
38633901
</div>
@@ -3894,7 +3932,7 @@ const Shell = () => {
38943932
onClick={startEditingFee}
38953933
className="w-full text-left px-3 py-2 rounded-md bg-canvas-bg-subtle text-canvas-text border border-canvas-border hover:bg-canvas-bg-hover transition-colors cursor-pointer"
38963934
>
3897-
{feeDisplayText()} {feeUnit === 'xch' ? 'XCH' : 'mojos'}
3935+
{feeDisplayText()} {feeUnit === 'xch' ? currency.xch : currency.mojos}
38983936
</button>
38993937
)}
39003938
</div>
@@ -3932,6 +3970,32 @@ const Shell = () => {
39323970
) : (
39333971
<div className="flex flex-col justify-center items-center w-full px-4 py-6 gap-4">
39343972
<p className="text-lg font-semibold text-canvas-text-contrast">Choose Connection</p>
3973+
<div className="w-full max-w-sm flex flex-col items-center gap-1">
3974+
<span className="text-sm text-canvas-text">Network</span>
3975+
<div className="flex rounded-md border border-canvas-border overflow-hidden text-xs">
3976+
<button
3977+
type="button"
3978+
disabled={networkLocked}
3979+
onClick={() => handleNetworkChange('mainnet')}
3980+
className={`px-3 py-1 transition-colors ${network === 'mainnet' ? 'bg-canvas-bg-active font-semibold' : networkLocked ? '' : 'hover:bg-canvas-bg-hover'} ${networkLocked ? 'opacity-40 cursor-default' : ''}`}
3981+
>
3982+
Mainnet
3983+
</button>
3984+
<button
3985+
type="button"
3986+
disabled={networkLocked}
3987+
onClick={() => handleNetworkChange('testnet')}
3988+
className={`px-3 py-1 transition-colors border-l border-canvas-border ${network === 'testnet' ? 'bg-canvas-bg-active font-semibold' : networkLocked ? '' : 'hover:bg-canvas-bg-hover'} ${networkLocked ? 'opacity-40 cursor-default' : ''}`}
3989+
>
3990+
Testnet
3991+
</button>
3992+
</div>
3993+
{networkLocked && (
3994+
<p className="text-xs text-canvas-text text-center">
3995+
Network is locked for this session
3996+
</p>
3997+
)}
3998+
</div>
39353999
<div className="w-full max-w-sm flex flex-col gap-3">
39364000
<Button
39374001
variant="solid"
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { PREFERENCES_KEY } from '../hooks/savePreferences';
2+
3+
/**
4+
* User-visible currency labels for the selected Chia network. Testnet uses the
5+
* T-prefixed nomenclature (TXCH / TMojo); mainnet keeps the standard names.
6+
*
7+
* The network preference is read from the persisted `appPreferences` blob so
8+
* this stays a side-effect-free leaf module (no dependency on the save graph,
9+
* which imports `util` and would otherwise form an import cycle).
10+
*/
11+
export interface CurrencyLabels {
12+
/** Uppercase ticker: XCH / TXCH. */
13+
xch: string;
14+
/** Lowercase word for the coin: chia / TXCH. */
15+
chia: string;
16+
/** Singular sub-unit, lowercase: mojo / TMojo. */
17+
mojo: string;
18+
/** Plural sub-unit, lowercase: mojos / TMojos. */
19+
mojos: string;
20+
/** Uppercase sub-unit: MOJO / TMOJO. */
21+
MOJO: string;
22+
}
23+
24+
function readNetworkIsTestnet(): boolean {
25+
try {
26+
const raw = localStorage.getItem(PREFERENCES_KEY);
27+
if (!raw) return false;
28+
const parsed = JSON.parse(raw) as { network?: unknown };
29+
return parsed.network === 'testnet';
30+
} catch {
31+
return false;
32+
}
33+
}
34+
35+
export function isTestnet(): boolean {
36+
return readNetworkIsTestnet();
37+
}
38+
39+
export function getCurrencyLabels(): CurrencyLabels {
40+
const t = readNetworkIsTestnet();
41+
return {
42+
xch: t ? 'TXCH' : 'XCH',
43+
chia: t ? 'TXCH' : 'chia',
44+
mojo: t ? 'TMojo' : 'mojo',
45+
mojos: t ? 'TMojos' : 'mojos',
46+
MOJO: t ? 'TMOJO' : 'MOJO',
47+
};
48+
}

0 commit comments

Comments
 (0)