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:
martbost
2026-09-16 18:49:33 -05:00
parent 6f1c9ff8ca
commit 49a0778a53
4 changed files with 124 additions and 13 deletions
+19 -10
View File
@@ -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
// 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.
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{
// 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…';
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 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();
if(v&&v.ok) $('resultSub').innerHTML='Signed in. Taking you to your position page…';
else $('resultSub').innerHTML='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){} }
var v=await (await fetch('/api/public/msg-verify',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({address:account,signature:sig,id:String(newId)})})).json();
$('resultSub').innerHTML=(v&&v.ok)?'Signed in. Taking you to your position page…':'Welcome to the team! Taking you to your position page…';
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){} }
@@ -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; }
var newId=receiptEventId(rc);
if(newId){
reportJoin(newId);
var reported=reportJoin(newId);
$('result').style.display='block';
$('resultId').textContent='#'+newId;
$('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');
try{ el_scroll('result'); }catch(e){}
try{sessionStorage.setItem('rmc.fresh','1');}catch(e){}
signInNewMember(newId);
setTimeout(function(){ location.href='/my/'+newId; }, 4500);
signInNewMember(newId,reported); // redirects when it settles
} else {
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;
@@ -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 src=(window.ctbGetSource?window.ctbGetSource():'(direct)');
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})
}).catch(function(){});
}catch(e){}