Files
rm-circle-team-router/qa/signin-fallback.mjs
T
martbost 49a0778a53 Post-join sign-in actually works: live position lookup for a cold index, ordered calls, redirect no longer races the signature
#787 registered at 18:35 CT, 22 minutes after the first join-flow fix, and still had no profile. Two
causes, both fixed:

1. messages.verifyChallenge resolved the wallet through chain.memberIdByAccount, which reads the
   CACHED index. Seconds after a registration that wallet is not in it, so the signature was rejected
   with "No RM Circle position is registered to this wallet". It now accepts an idHint (the position
   id from the member's own registration receipt) and, on a cache miss, reads that id live from the
   contract via chain.verifyMember, minting only when the contract says this exact wallet owns it.
   That is a stronger proof than the cache, not a weaker one. Now async; the single call site awaits.

2. join-now.js fired the sign-in and a 4.5s redirect in parallel, so the page could navigate away
   while the wallet was still showing the signature prompt, and it did not wait for submit-id (which
   runs the live verifyMember server-side that seeds the index). It now awaits the report, passes the
   receipt id, and redirects only once the signature settles, with a 120s bailout.

qa/signin-fallback.mjs (7 assertions) proves the cold-index path with real secp256k1 signatures and
covers the abuse cases: a hint for a position the wallet does not own is refused, and a signature from
another wallet is refused. Existing suites still pass: profiles-unit 28, gate-e2e 47.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:49:33 -05:00

88 lines
4.2 KiB
JavaScript

// Proves the post-registration sign-in fix: a wallet whose position is NOT yet in the
// cached chain index (the state a member is in for the first minute after joining) can
// still sign in, via a live contract read of the id from their registration receipt,
// and only when the contract agrees that wallet owns that id.
import { createRequire } from 'node:module';
import crypto from 'node:crypto';
import fs from 'node:fs';
const ROOT = 'D:/Projects/HighRisk/The RM Circle/promos/';
const require = createRequire(ROOT + 'package.json');
const secp = require(ROOT + 'vendor/secp256k1.js');
const { keccak256 } = require(ROOT + 'vendor/sha3.js');
secp.utils.hmacSha256Sync = (key, ...msgs) => {
const h = crypto.createHmac('sha256', Buffer.from(key));
msgs.forEach(m => h.update(Buffer.from(m)));
return Uint8Array.from(h.digest());
};
const messages = require(ROOT + 'messages.js');
const DIR = 'D:/tmp/rmc-signin-test';
fs.rmSync(DIR, { recursive: true, force: true }); fs.mkdirSync(DIR, { recursive: true });
// a wallet we control
const priv = Buffer.from('59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', 'hex');
const pub = secp.getPublicKey(priv, false);
const ADDR = '0x' + keccak256(Buffer.from(pub.slice(1))).slice(-40);
// a chain stub in the exact state that bit #787: the index has NOT seen this member yet
let liveReads = 0, seeded = false;
const chain = {
memberIdByAccount: () => (seeded ? 4242 : null), // cache miss until verifyMember seeds it
verifyMember: async (id) => { liveReads++; seeded = true; return id === 4242 ? { registered: true, account: ADDR } : { registered: false }; },
isInTeam: () => true
};
messages.init({ dataDir: DIR, chain, isProd: false });
const sign = (msg) => {
const m = Buffer.from(msg, 'utf8');
const pre = Buffer.from('\x19Ethereum Signed Message:\n' + m.length, 'utf8');
const digest = Buffer.from(keccak256(Buffer.concat([pre, m])), 'hex');
const [sig, rec] = secp.signSync(digest, priv, { recovered: true, der: false });
return '0x' + Buffer.from(sig).toString('hex') + (27 + rec).toString(16).padStart(2, '0');
};
const ok = [], bad = [];
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
// 1. the old behaviour: no id hint, index cold -> refused
let ch = messages.makeChallenge(ADDR);
let r = await messages.verifyChallenge(ADDR, sign(ch));
t('cold index with no hint is refused', !!r.error && /No RM Circle position/.test(r.error), JSON.stringify(r));
t('no live read was attempted without a hint', liveReads === 0);
// 2. the fix: same cold index, but the receipt id is passed
ch = messages.makeChallenge(ADDR);
r = await messages.verifyChallenge(ADDR, sign(ch), '4242');
t('cold index WITH the receipt id signs in', !!r.token && r.id === 4242, JSON.stringify(r));
t('it did a live contract read', liveReads === 1);
// 3. the hint cannot be abused: a wrong id is refused because the chain disagrees
seeded = false;
ch = messages.makeChallenge(ADDR);
r = await messages.verifyChallenge(ADDR, sign(ch), '9999');
t('a hint for a position this wallet does not own is refused', !!r.error, JSON.stringify(r));
// 4. a forged signature is still refused even with a valid hint
seeded = false;
ch = messages.makeChallenge(ADDR);
const otherPriv = crypto.randomBytes(32);
const forge = (msg) => {
const m = Buffer.from(msg, 'utf8');
const pre = Buffer.from('\x19Ethereum Signed Message:\n' + m.length, 'utf8');
const digest = Buffer.from(keccak256(Buffer.concat([pre, m])), 'hex');
const [sig, rec] = secp.signSync(digest, otherPriv, { recovered: true, der: false });
return '0x' + Buffer.from(sig).toString('hex') + (27 + rec).toString(16).padStart(2, '0');
};
r = await messages.verifyChallenge(ADDR, forge(ch), '4242');
t('a signature from another wallet is refused', !!r.error && /different account/.test(r.error), JSON.stringify(r));
// 5. once the indexer catches up, no live read is needed
seeded = true; liveReads = 0;
ch = messages.makeChallenge(ADDR);
r = await messages.verifyChallenge(ADDR, sign(ch), '4242');
t('warm index signs in with no live read', !!r.token && liveReads === 0);
console.log('PASS ' + ok.length);
for (const b of bad) console.log('FAIL ' + b);
process.exit(bad.length ? 1 : 0);