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>
This commit is contained in:
+17
-2
@@ -74,7 +74,13 @@ function makeChallenge(address) {
|
|||||||
challenges.set(a, { message, exp: Date.now() + CHALLENGE_TTL });
|
challenges.set(a, { message, exp: Date.now() + CHALLENGE_TTL });
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
function verifyChallenge(address, signature) {
|
// idHint: the position id a brand-new member just got from their registration
|
||||||
|
// receipt. memberIdByAccount reads the CACHED index, which the indexer has not
|
||||||
|
// refreshed yet seconds after a join (#787, 2026-09-16: signed in 22 minutes after
|
||||||
|
// the join-flow fix shipped and was still rejected with "no position registered").
|
||||||
|
// On a cache miss we read that id straight off the chain and accept it only when
|
||||||
|
// the contract says this exact wallet owns it, so the proof is stronger, not weaker.
|
||||||
|
async function verifyChallenge(address, signature, idHint) {
|
||||||
const a = address.toLowerCase();
|
const a = address.toLowerCase();
|
||||||
const ch = challenges.get(a);
|
const ch = challenges.get(a);
|
||||||
if (!ch || ch.exp < Date.now()) return { error: 'Challenge expired - tap sign-in again.' };
|
if (!ch || ch.exp < Date.now()) return { error: 'Challenge expired - tap sign-in again.' };
|
||||||
@@ -85,7 +91,16 @@ function verifyChallenge(address, signature) {
|
|||||||
+ rec.slice(0, 6) + '…' + rec.slice(-4) + '. Switch your wallet to the account that owns this '
|
+ rec.slice(0, 6) + '…' + rec.slice(-4) + '. Switch your wallet to the account that owns this '
|
||||||
+ 'position, reload the page, then tap sign-in again.', signer: rec, expected: a };
|
+ 'position, reload the page, then tap sign-in again.', signer: rec, expected: a };
|
||||||
challenges.delete(a);
|
challenges.delete(a);
|
||||||
const id = chain.memberIdByAccount(a);
|
let id = chain.memberIdByAccount(a);
|
||||||
|
if (!id && idHint) {
|
||||||
|
const hint = Number(idHint);
|
||||||
|
if (Number.isInteger(hint) && hint > 0) {
|
||||||
|
try {
|
||||||
|
const m = await chain.verifyMember(hint); // live contract read; also seeds the index
|
||||||
|
if (m && m.registered && String(m.account || '').toLowerCase() === a) id = hint;
|
||||||
|
} catch (e) { console.error('verifyChallenge live lookup', e.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!id) return { error: 'No RM Circle position is registered to this wallet.' };
|
if (!id) return { error: 'No RM Circle position is registered to this wallet.' };
|
||||||
const token = crypto.randomBytes(32).toString('hex');
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
sessions.set(token, { address: a, id, expires: Date.now() + SESSION_TTL });
|
sessions.set(token, { address: a, id, expires: Date.now() + SESSION_TTL });
|
||||||
|
|||||||
+19
-10
@@ -81,17 +81,27 @@ fetch('/api/public/config').then(function(r){return r.json()}).then(function(c){
|
|||||||
// it opens their inbox session so the member-profile gate can ask for a username
|
// it opens their inbox session so the member-profile gate can ask for a username
|
||||||
// and email on the dashboard. Best-effort — if they decline, the join still stands
|
// and email on the dashboard. Best-effort — if they decline, the join still stands
|
||||||
// and they are asked again the first time they sign in.
|
// and they are asked again the first time they sign in.
|
||||||
async function signInNewMember(newId){
|
async function signInNewMember(newId,reported){
|
||||||
|
var go=function(){ try{ location.href='/my/'+newId; }catch(e){} };
|
||||||
|
var bailout=setTimeout(go,120000); // wallet never answered: leave anyway
|
||||||
try{
|
try{
|
||||||
|
// submit-id runs a LIVE verifyMember server-side, which seeds the index with
|
||||||
|
// this brand-new position. Let it finish before asking the server who we are.
|
||||||
|
try{ await reported; }catch(e){}
|
||||||
$('resultSub').innerHTML='Welcome to the team! One free signature sets up your member profile…';
|
$('resultSub').innerHTML='Welcome to the team! One free signature sets up your member profile…';
|
||||||
var ch=await (await fetch('/api/public/msg-challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account})})).json();
|
var ch=await (await fetch('/api/public/msg-challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account})})).json();
|
||||||
if(!ch||!ch.message) return;
|
if(!ch||!ch.message){ clearTimeout(bailout); setTimeout(go,2500); return; }
|
||||||
var hex='0x'; for(var i=0,b=new TextEncoder().encode(ch.message);i<b.length;i++) hex+=b[i].toString(16).padStart(2,'0');
|
var hex='0x'; for(var i=0,b=new TextEncoder().encode(ch.message);i<b.length;i++) hex+=b[i].toString(16).padStart(2,'0');
|
||||||
var sig=await req('personal_sign',[hex,account]);
|
var sig=await req('personal_sign',[hex,account]);
|
||||||
var v=await (await fetch('/api/public/msg-verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account,signature:sig})})).json();
|
var v=await (await fetch('/api/public/msg-verify',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
if(v&&v.ok) $('resultSub').innerHTML='Signed in. Taking you to your position page…';
|
body:JSON.stringify({address:account,signature:sig,id:String(newId)})})).json();
|
||||||
else $('resultSub').innerHTML='Welcome to the team! Taking you to your position page…';
|
$('resultSub').innerHTML=(v&&v.ok)?'Signed in. Taking you to your position page…':'Welcome to the team! Taking you to your position page…';
|
||||||
}catch(e){ try{ $('resultSub').innerHTML='Welcome to the team! Taking you to your position page…'; }catch(x){} }
|
if(!(v&&v.ok)) console.warn('post-join sign-in:', v&&v.error);
|
||||||
|
}catch(e){
|
||||||
|
try{ $('resultSub').innerHTML='Welcome to the team! Taking you to your position page…'; }catch(x){}
|
||||||
|
}
|
||||||
|
clearTimeout(bailout);
|
||||||
|
setTimeout(go,1800);
|
||||||
}
|
}
|
||||||
async function refreshBalance(){ try{ var b=await req('eth_getBalance',[account,'latest']); lastBal=BigInt(b); $('bal').textContent=polStr(lastBal)+' POL'; updateFundBox(); }catch(e){} }
|
async function refreshBalance(){ try{ var b=await req('eth_getBalance',[account,'latest']); lastBal=BigInt(b); $('bal').textContent=polStr(lastBal)+' POL'; updateFundBox(); }catch(e){} }
|
||||||
|
|
||||||
@@ -182,7 +192,7 @@ fetch('/api/public/config').then(function(r){return r.json()}).then(function(c){
|
|||||||
if(rc.status==='0x0'){ log('⚠️ The transaction reverted — no position created, and your POL was returned (minus gas). Please try again.','err'); $('joinBtn').disabled=false; return; }
|
if(rc.status==='0x0'){ log('⚠️ The transaction reverted — no position created, and your POL was returned (minus gas). Please try again.','err'); $('joinBtn').disabled=false; return; }
|
||||||
var newId=receiptEventId(rc);
|
var newId=receiptEventId(rc);
|
||||||
if(newId){
|
if(newId){
|
||||||
reportJoin(newId);
|
var reported=reportJoin(newId);
|
||||||
$('result').style.display='block';
|
$('result').style.display='block';
|
||||||
$('resultId').textContent='#'+newId;
|
$('resultId').textContent='#'+newId;
|
||||||
$('resultSub').innerHTML='Welcome to the team! Taking you to your position page…';
|
$('resultSub').innerHTML='Welcome to the team! Taking you to your position page…';
|
||||||
@@ -190,8 +200,7 @@ fetch('/api/public/config').then(function(r){return r.json()}).then(function(c){
|
|||||||
log('🎉 Confirmed — your position is <strong>#'+newId+'</strong>. Redirecting to your dashboard…','ok');
|
log('🎉 Confirmed — your position is <strong>#'+newId+'</strong>. Redirecting to your dashboard…','ok');
|
||||||
try{ el_scroll('result'); }catch(e){}
|
try{ el_scroll('result'); }catch(e){}
|
||||||
try{sessionStorage.setItem('rmc.fresh','1');}catch(e){}
|
try{sessionStorage.setItem('rmc.fresh','1');}catch(e){}
|
||||||
signInNewMember(newId);
|
signInNewMember(newId,reported); // redirects when it settles
|
||||||
setTimeout(function(){ location.href='/my/'+newId; }, 4500);
|
|
||||||
} else {
|
} else {
|
||||||
log('Confirmed on-chain. Open your dashboard at <a href="/my">/my</a> and enter your new ID (also shown on Polygonscan).','ok');
|
log('Confirmed on-chain. Open your dashboard at <a href="/my">/my</a> and enter your new ID (also shown on Polygonscan).','ok');
|
||||||
$('joinBtn').disabled=false;
|
$('joinBtn').disabled=false;
|
||||||
@@ -209,7 +218,7 @@ fetch('/api/public/config').then(function(r){return r.json()}).then(function(c){
|
|||||||
var name=(nameEl&&nameEl.value?nameEl.value.trim().slice(0,60):'')||('Self-enrolled #'+newId);
|
var name=(nameEl&&nameEl.value?nameEl.value.trim().slice(0,60):'')||('Self-enrolled #'+newId);
|
||||||
var src=(window.ctbGetSource?window.ctbGetSource():'(direct)');
|
var src=(window.ctbGetSource?window.ctbGetSource():'(direct)');
|
||||||
var cid=(window.ctbGetClickId?window.ctbGetClickId():'');
|
var cid=(window.ctbGetClickId?window.ctbGetClickId():'');
|
||||||
fetch('/api/public/submit-id',{method:'POST',headers:{'Content-Type':'application/json'},
|
return fetch('/api/public/submit-id',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
body:JSON.stringify({newId:String(newId),memberName:name,sponsorId:String(sponsorId||'?'),source:src,clickid:cid})
|
body:JSON.stringify({newId:String(newId),memberName:name,sponsorId:String(sponsorId||'?'),source:src,clickid:cid})
|
||||||
}).catch(function(){});
|
}).catch(function(){});
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// 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);
|
||||||
@@ -775,7 +775,7 @@ async function handleApi(req,res,pathname){
|
|||||||
if(req.method==='POST'&&pathname==='/api/public/msg-verify'){
|
if(req.method==='POST'&&pathname==='/api/public/msg-verify'){
|
||||||
const b=await bodyJson(req);
|
const b=await bodyJson(req);
|
||||||
if(!b||typeof b.address!=='string'||!messages.ADDR_RE.test(b.address)||typeof b.signature!=='string')return json(res,400,{error:'Invalid request.'});
|
if(!b||typeof b.address!=='string'||!messages.ADDR_RE.test(b.address)||typeof b.signature!=='string')return json(res,400,{error:'Invalid request.'});
|
||||||
const r=messages.verifyChallenge(b.address,b.signature);
|
const r=await messages.verifyChallenge(b.address,b.signature,b.id);
|
||||||
if(r.error)return json(res,401,{error:r.error});
|
if(r.error)return json(res,401,{error:r.error});
|
||||||
return json(res,200,{ok:true,id:r.id},{'Set-Cookie':messages.sessionCookie(r.token)});
|
return json(res,200,{ok:true,id:r.id},{'Set-Cookie':messages.sessionCookie(r.token)});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user