Mainnet
This commit is contained in:
Executable
+16
@@ -0,0 +1,16 @@
|
||||
import { CONSENSUS, MAX_TARGET, NETWORK } from './testnet.ts';
|
||||
import { NETWORK_GENESIS_TIME } from './network.ts';
|
||||
|
||||
export type ChainIdentity = { chainId: string; genesis: string };
|
||||
let identity: Promise<ChainIdentity> | undefined;
|
||||
|
||||
/** Derive the same public manifest as the node from this immutable browser build. */
|
||||
export function expectedChainIdentity(): Promise<ChainIdentity> {
|
||||
if (!identity) identity = (async () => {
|
||||
const digestHex = async (value: string) => Array.from(new Uint8Array(await crypto.subtle.digest(
|
||||
'SHA-256', new TextEncoder().encode(value))), byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
const chainId = await digestHex(JSON.stringify([NETWORK, CONSENSUS, NETWORK_GENESIS_TIME, MAX_TARGET]));
|
||||
return { chainId, genesis: await digestHex(`${NETWORK}:genesis:${chainId}`) };
|
||||
})();
|
||||
return identity;
|
||||
}
|
||||
+30
-5
@@ -1,16 +1,41 @@
|
||||
/** Selection is fixed at server startup / browser build, never learned from an untrusted RPC. */
|
||||
export type NetworkProfile = 'testnet' | 'mainnet';
|
||||
export function networkProfile(value: string | undefined): NetworkProfile {
|
||||
const profile = value ?? 'testnet';
|
||||
const profile = value ?? 'mainnet';
|
||||
if (profile !== 'testnet' && profile !== 'mainnet') {
|
||||
throw new Error('WIKIDECK_NETWORK must be testnet or mainnet');
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
export const PROFILE = networkProfile(typeof process !== 'undefined' && process.versions?.node
|
||||
const isNodeRuntime = typeof process !== 'undefined' && !!process.versions?.node;
|
||||
export const PROFILE = networkProfile(isNodeRuntime
|
||||
? process.env.WIKIDECK_NETWORK : import.meta.env?.VITE_WIKIDECK_NETWORK);
|
||||
export const IS_MAINNET = PROFILE === 'mainnet';
|
||||
export const NETWORK = IS_MAINNET ? 'wikideck-randomx-mainnet-1' as const : 'wikideck-randomx-testnet-6' as const;
|
||||
// Candidate mainnet genesis: 2027-01-01 00:00 UTC. NOT a commitment to a launch date.
|
||||
export const NETWORK_GENESIS_TIME = IS_MAINNET ? 1_798_761_600 : 1_792_411_200;
|
||||
export const NETWORK_LABEL = IS_MAINNET ? 'MAINNET CANDIDATE' : 'TESTNET';
|
||||
const TESTNET_GENESIS_TIME = 1_792_411_200;
|
||||
const DEFAULT_MAINNET_GENESIS_TIME = 1_798_761_600; // 2027-01-01 UTC candidate; deployment must freeze one value.
|
||||
function genesisTime(raw: string | undefined, profile: NetworkProfile): number {
|
||||
const value = raw ?? String(profile === 'mainnet' ? DEFAULT_MAINNET_GENESIS_TIME : TESTNET_GENESIS_TIME);
|
||||
if (!/^(0|[1-9]\d{8,10})$/.test(value)) throw new Error('WIKIDECK_GENESIS_TIME must be a canonical Unix-seconds integer');
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1_700_000_000 || parsed > 4_102_444_800) {
|
||||
throw new Error('WIKIDECK_GENESIS_TIME is outside the supported range');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
export const NETWORK_GENESIS_TIME = genesisTime(isNodeRuntime
|
||||
? process.env.WIKIDECK_GENESIS_TIME : import.meta.env?.VITE_WIKIDECK_GENESIS_TIME, PROFILE);
|
||||
if (isNodeRuntime) {
|
||||
const imageProfile = process.env.WIKIDECK_IMAGE_NETWORK;
|
||||
const imageGenesisTime = process.env.WIKIDECK_IMAGE_GENESIS_TIME;
|
||||
if (imageProfile !== undefined && imageProfile !== PROFILE) {
|
||||
throw new Error('Runtime network profile does not match the immutable image manifest');
|
||||
}
|
||||
if (imageGenesisTime !== undefined && Number(imageGenesisTime) !== NETWORK_GENESIS_TIME) {
|
||||
throw new Error('Runtime genesis time does not match the immutable image manifest');
|
||||
}
|
||||
if (imageGenesisTime !== undefined && !/^(0|[1-9]\d{8,10})$/.test(imageGenesisTime)) {
|
||||
throw new Error('Image genesis time manifest is malformed');
|
||||
}
|
||||
}
|
||||
export const NETWORK_LABEL = IS_MAINNET ? 'MAINNET' : 'TESTNET';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NETWORK, type CanonicalPage, type TestnetBlock, type TestnetSnapshot, type TestnetSummary } from './testnet.ts';
|
||||
import { expectedChainIdentity } from './chain-identity.ts';
|
||||
|
||||
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_HISTORY_BYTES = 64 * 1024 * 1024;
|
||||
@@ -45,7 +46,9 @@ export class StateClient {
|
||||
}
|
||||
private async summary(signal: AbortSignal) {
|
||||
const s = await this.json<TestnetSummary>('summary', signal);
|
||||
if (s.network !== NETWORK || s.algorithm !== 'RandomX' || !hex.test(s.chainId) || !hex.test(s.tip) || !hex.test(s.genesis) ||
|
||||
const expected = await expectedChainIdentity();
|
||||
if (s.network !== NETWORK || s.chainId !== expected.chainId || s.genesis !== expected.genesis ||
|
||||
s.algorithm !== 'RandomX' || !hex.test(s.chainId) || !hex.test(s.tip) || !hex.test(s.genesis) ||
|
||||
!Number.isSafeInteger(s.height) || s.height < 0 || !Array.isArray(s.pendingTransfers) || s.pendingTransfers.length > 256 ||
|
||||
!/^\d{1,256}$/.test(s.cumulativeWork)) throw new Error('Incompatible node summary.');
|
||||
if (s.height > MAX_CLIENT_BLOCKS) throw new Error('This node exceeds the browser history limit (10,000 blocks). A paged explorer/wallet is required; no partial ownership state will be shown.');
|
||||
|
||||
@@ -10,6 +10,7 @@ export const CONSENSUS = {
|
||||
maxTransactions: 64, maxTradeCards: 8, coinbaseMaturity: 2, maxBlockBytes: 64 * 1024,
|
||||
articleSource: 'wikipedia-random-with-image-and-stats-v1', atomicTrades: 'signed-two-party-bundle-v1',
|
||||
} as const;
|
||||
export const MAX_TARGET = `0fff${'f'.repeat(60)}`;
|
||||
export type NftTransfer = {
|
||||
network: typeof NETWORK; chainId: string; cardHash: string; from: string; to: string;
|
||||
sequence: number; publicKey: string; signature: string;
|
||||
|
||||
+26
-9
@@ -1,12 +1,12 @@
|
||||
// Browser/Node WebCrypto only: this module never reads storage or sends data.
|
||||
import { NETWORK } from './network.ts';
|
||||
import { IS_MAINNET, NETWORK, NETWORK_GENESIS_TIME } from './network.ts';
|
||||
import { expectedChainIdentity } from './chain-identity.ts';
|
||||
const ITERATIONS = 600_000;
|
||||
const MAX_BYTES = 32 * 1024;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export type WalletBackup = {
|
||||
type WalletBackupBase = {
|
||||
format: 'wikideck-wallet-backup';
|
||||
version: 1;
|
||||
network: typeof NETWORK;
|
||||
kdf: 'PBKDF2-SHA256';
|
||||
iterations: 600000;
|
||||
@@ -16,6 +16,10 @@ export type WalletBackup = {
|
||||
publicKey: string;
|
||||
ciphertext: string;
|
||||
};
|
||||
export type WalletBackup = WalletBackupBase & (
|
||||
| { version: 1; chainId?: never }
|
||||
| { version: 2; chainId: string }
|
||||
);
|
||||
|
||||
function encode(bytes: ArrayBuffer | Uint8Array<ArrayBuffer>): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
||||
@@ -54,27 +58,38 @@ async function encryptionKey(password: string, salt: Uint8Array<ArrayBuffer>, us
|
||||
// Fixed-order AAD authenticates every header field, including identity/network.
|
||||
function additionalData(backup: WalletBackup): Uint8Array<ArrayBuffer> {
|
||||
return encoder.encode(JSON.stringify([
|
||||
backup.format, backup.version, backup.network, backup.kdf, backup.iterations,
|
||||
backup.format, backup.version, backup.network,
|
||||
...(backup.version === 2 ? [backup.chainId] : []), backup.kdf, backup.iterations,
|
||||
backup.cipher, backup.salt, backup.iv, backup.publicKey,
|
||||
]));
|
||||
}
|
||||
|
||||
function validateBackup(value: unknown): WalletBackup {
|
||||
async function validateBackup(value: unknown): Promise<WalletBackup> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
||||
![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
||||
throw new Error('Invalid wallet backup.');
|
||||
}
|
||||
const names = ['format', 'version', 'network', 'kdf', 'iterations', 'cipher', 'salt', 'iv', 'publicKey', 'ciphertext'];
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
const version = descriptors.version && Object.hasOwn(descriptors.version, 'value') ? descriptors.version.value : undefined;
|
||||
if (version !== 1 && version !== 2) throw new Error('Unsupported wallet backup format, network, or cryptography.');
|
||||
const names = ['format', 'version', 'network', ...(version === 2 ? ['chainId'] : []),
|
||||
'kdf', 'iterations', 'cipher', 'salt', 'iv', 'publicKey', 'ciphertext'];
|
||||
if (Reflect.ownKeys(value).length !== names.length || names.some(name =>
|
||||
!Object.hasOwn(descriptors, name) || !Object.hasOwn(descriptors[name], 'value'))) {
|
||||
throw new Error('Invalid wallet backup fields.');
|
||||
}
|
||||
const backup = value as WalletBackup;
|
||||
if (backup.format !== 'wikideck-wallet-backup' || backup.version !== 1 || backup.network !== NETWORK ||
|
||||
if (backup.format !== 'wikideck-wallet-backup' || backup.network !== NETWORK ||
|
||||
backup.kdf !== 'PBKDF2-SHA256' || backup.iterations !== ITERATIONS || backup.cipher !== 'AES-256-GCM') {
|
||||
throw new Error('Unsupported wallet backup format, network, or cryptography.');
|
||||
}
|
||||
if (backup.version === 2) {
|
||||
const expected = await expectedChainIdentity();
|
||||
if (backup.chainId !== expected.chainId) throw new Error('Wallet backup is for a different chain.');
|
||||
} else if (IS_MAINNET || NETWORK_GENESIS_TIME !== 1_792_411_200) {
|
||||
// Legacy v1 headers had no chain identity, so accept them only on the original testnet.
|
||||
throw new Error('Legacy wallet backups are supported only on the original testnet.');
|
||||
}
|
||||
decode(backup.salt, 16, 16);
|
||||
decode(backup.iv, 12, 12);
|
||||
decode(backup.publicKey, 91, 91);
|
||||
@@ -82,6 +97,7 @@ function validateBackup(value: unknown): WalletBackup {
|
||||
if (encoder.encode(JSON.stringify(backup)).length > MAX_BYTES) throw new Error('Backup exceeds 32 KB.');
|
||||
// Snapshot primitives so callers cannot change headers during awaits.
|
||||
return { format: backup.format, version: backup.version, network: backup.network,
|
||||
...(backup.version === 2 ? { chainId: backup.chainId } : {}),
|
||||
kdf: backup.kdf, iterations: backup.iterations, cipher: backup.cipher,
|
||||
salt: backup.salt, iv: backup.iv, publicKey: backup.publicKey, ciphertext: backup.ciphertext };
|
||||
}
|
||||
@@ -110,9 +126,10 @@ async function validatePair(privateKey: CryptoKey, publicKey: string): Promise<s
|
||||
export async function encryptWallet(privateKey: CryptoKey, publicKey: string, password: string): Promise<WalletBackup> {
|
||||
passwordBytes(password, 12).fill(0);
|
||||
await validatePair(privateKey, publicKey);
|
||||
const { chainId } = await expectedChainIdentity();
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const backup: WalletBackup = { format: 'wikideck-wallet-backup', version: 1, network: NETWORK,
|
||||
const backup: WalletBackup = { format: 'wikideck-wallet-backup', version: 2, network: NETWORK, chainId,
|
||||
kdf: 'PBKDF2-SHA256', iterations: ITERATIONS, cipher: 'AES-256-GCM',
|
||||
salt: encode(salt), iv: encode(iv), publicKey, ciphertext: '' };
|
||||
const key = await encryptionKey(password, salt, 'encrypt');
|
||||
@@ -128,7 +145,7 @@ export async function encryptWallet(privateKey: CryptoKey, publicKey: string, pa
|
||||
export async function decryptWallet(backup: unknown, password: string): Promise<{
|
||||
privateKey: CryptoKey; publicKey: string; address: string;
|
||||
}> {
|
||||
const validated = validateBackup(backup); // Bound work before PBKDF2.
|
||||
const validated = await validateBackup(backup); // Bound work before PBKDF2.
|
||||
const key = await encryptionKey(password, decode(validated.salt, 16, 16), 'decrypt');
|
||||
let plaintext: Uint8Array<ArrayBuffer> | undefined;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user