Files
rm-circle-team-router/public/join-now.js
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

264 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
fetch('/api/public/config').then(function(r){return r.json()}).then(function(c){if(c&&c.polUsd>0)window.__polUsd=c.polUsd;}).catch(function(){});
/* RM Circle — public direct-join engine (dormant fallback; served only when
config.dappFallbackPublic is on). Talks only to window.ethereum (CSP-safe).
Auto-assigns the current rotation sponsor, registers the member's own wallet,
then sends them to their live position page /my/<newId>. Zero custody. */
(function(){
'use strict';
var CONTRACT='0x33bdaeefd6d17d80ae53816c916dfb26c4fb2daf';
var POLYGON_HEX='0x89';
var SEL_REGISTER='0x30de37e4'; // register(uint48 sponsorId, uint8 tier) payable
var SEL_GETMEMBER='0x2ada2596'; // getMember(address)
var SEL_GETCOSTS='0x735f87b9'; // getAllCosts()
var T_REGISTERED='0xe4a74887d749eb048f14bfef37b204477f3a5ff67055908b7c8cc62c202aef17';
var $=function(id){return document.getElementById(id);};
var eth=null; // resolved via the wallet picker on connect
var account=null, costs=null, sponsorId=null;
function log(msg,kind){var b=$('log');if(!b)return;var d=document.createElement('div');d.className='dj-log '+(kind||'');d.innerHTML=msg;b.appendChild(d);b.scrollTop=b.scrollHeight;}
function word(n){return BigInt(n).toString(16).padStart(64,'0');}
function addrWord(a){return a.toLowerCase().replace(/^0x/,'').padStart(64,'0');}
function polStr(wei){return (Number(wei)/1e18).toFixed(2);}
function hexWei(wei){return '0x'+BigInt(wei).toString(16);}
async function req(method,params){ if(!eth) throw new Error('No wallet found'); return await eth.request({method:method,params:params||[]}); }
async function ethCall(data){ return await req('eth_call',[{to:CONTRACT,data:data},'latest']); }
// Immutable contract constant (getAllCosts premium[0], verified on-chain).
// Fallback when the WALLET's internal RPC hiccups (MetaMask iOS surfaces
// that as "Load failed") — prices can never change, so baking it is safe.
var REG_PREM_WEI=343406593406593400832n;
async function loadCosts(){
try{
var r=await ethCall(SEL_GETCOSTS); var d=r.slice(2), w=[];
for(var i=0;i<d.length;i+=64) w.push(BigInt('0x'+d.slice(i,i+64)));
costs={ regPrem:w.slice(8,16) };
}catch(e){ costs={ regPrem:[REG_PREM_WEI] }; }
return costs;
}
function regValue(){ return costs.regPrem[0]*105n/100n; } // premium base * 1.05 (verified)
async function readPosition(addr){
try{ var r=await ethCall(SEL_GETMEMBER+addrWord(addr)); if(!r||r==='0x') return null;
var d=r.slice(2), level=parseInt(d.slice(5*64,6*64),16); return level?{level:level}:null; }catch(e){ return null; }
}
async function ensurePolygon(){
var cid=await req('eth_chainId'); if(cid===POLYGON_HEX) return true;
try{ await req('wallet_switchEthereumChain',[{chainId:POLYGON_HEX}]); return true; }
catch(e){ if(e&&e.code===4902){ await req('wallet_addEthereumChain',[{chainId:POLYGON_HEX,chainName:'Polygon Mainnet',nativeCurrency:{name:'POL',symbol:'POL',decimals:18},rpcUrls:['https://polygon-rpc.com'],blockExplorerUrls:['https://polygonscan.com']}]); return true; } throw e; }
}
async function waitReceipt(tx){ for(var i=0;i<60;i++){ try{ var r=await req('eth_getTransactionReceipt',[tx]); if(r) return r; }catch(e){} await new Promise(function(s){setTimeout(s,3000);}); } return null; }
function receiptEventId(rc){ var id=null; (rc.logs||[]).forEach(function(l){ if(l.address&&l.address.toLowerCase()===CONTRACT&&l.topics&&l.topics[0]===T_REGISTERED) id=parseInt(l.topics[1],16); }); return id; }
async function loadSponsor(){
try{
// personal invite: ?ref=<id> -> place in that member's leg via the site's moving-link joinTarget
var qp=new URLSearchParams(location.search);
var ref=qp.get('ref');
// Direct link (?direct=1): opt out of rotation — register straight under
// the inviter (spillover in their team), never the next-to-qualify member.
if(ref&&/^\d{1,15}$/.test(ref)&&qp.get('direct')==='1'){
sponsorId=String(ref); $('sponsorLine').innerHTML='You’ll join <strong>directly under #'+ref+'</strong> — a spillover placement in their team.'; return;
}
if(ref&&/^\d{1,15}$/.test(ref)){
try{ var rm=await (await fetch('/api/public/member?id='+ref)).json();
// position configured to default to direct, not explicitly overridden
if(rm&&rm.directDefault&&qp.get('direct')!=='0'){
sponsorId=String(ref); $('sponsorLine').innerHTML='You’ll join <strong>directly under #'+ref+'</strong> — a spillover placement in their team.'; return;
}
var t=rm&&rm.joinTarget;
if(t&&t.id){ sponsorId=String(t.id); $('sponsorLine').innerHTML='You’ll join under <strong>#'+t.id+'</strong>'+(String(t.id)!==String(ref)?' — the next open spot in #'+ref+'’s team':'')+'.'; return; }
}catch(e){}
}
var j=await (await fetch('/api/public/current-sponsor')).json(); var sp=j&&j.sponsor;
if(sp&&sp.id){ sponsorId=String(sp.id); $('sponsorLine').innerHTML='You’ll join the team under <strong>#'+sp.id+(sp.name?' ('+sp.name+')':'')+'</strong> — the current team placement.'; }
else $('sponsorLine').textContent='Loading the current team placement…';
}catch(e){ $('sponsorLine').textContent='Could not load the current sponsor — please refresh.'; }
}
var lastBal=null;
// One free signature straight after the join, while the wallet is still connected:
// 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,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){ 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,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){} }
// Card on-ramp: shown only when the connected wallet can't cover the entry.
function updateFundBox(){
var box=$('fundBox'); if(!box||!costs||lastBal===null) return;
var need=regValue();
if(lastBal>=need){ box.style.display='none'; return; }
var shortPol=Math.max(62,Math.ceil(Number(need-lastBal)/1e18)+5);
$('fundMsg').textContent='This wallet holds '+polStr(lastBal)+' POL; the Premium entry is '+polStr(need)+' POL plus a little gas (like a car, every blockchain transaction burns a bit of fuel - keep some in the tank). You can buy the remaining ~'+shortPol+' POL'+(window.__polUsd>0?' (about $'+Math.round(Number(shortPol.replace(/,/g,''))*window.__polUsd)+')':'')+' with a debit/credit card, Apple Pay, or Google Pay — delivered straight to this wallet.';
box.dataset.pol=shortPol;
box.style.display='block';
}
async function openMoonpay(){
try{
var box=$('fundBox');
var r=await (await fetch('/api/public/moonpay-url?address='+encodeURIComponent(account||'')+'&pol='+encodeURIComponent((box&&box.dataset.pol)||''))).json();
if(r&&r.url){ window.open(r.url,'_blank','noopener'); log(r.signed?'MoonPay opened in a new tab with your wallet address pre-filled — complete the purchase there, then come back and refresh your balance.':'MoonPay opened in a new tab. Choose POL on the Polygon network and paste YOUR wallet address ('+account+') as the destination, then come back and refresh your balance.','ok'); }
}catch(e){ log('Could not open MoonPay: '+(e.message||e),'err'); }
}
async function finishConnect(){
await loadCosts();
$('wallet').textContent=account.slice(0,6)+'…'+account.slice(-4);
$('connected').style.display='block'; $('connectBtn').style.display='none';
await refreshBalance();
$('joinCost').textContent=polStr(regValue())+' POL';
var pos=await readPosition(account);
if(pos){ $('already').style.display='block'; $('already').innerHTML='This wallet already holds position — <a href="/my">open your dashboard</a>. To create a <em>new</em> position, connect a different wallet.'; $('joinBtn').disabled=true; }
$('joinBox').style.display='block';
}
async function connect(){
try{
eth=await window.RMCWallet.pick();
if(!eth){ showWalletHelp(); log('No wallet detected in this browser. On a phone, use the "Open in your wallet" buttons above — on a computer, install/unlock MetaMask and refresh.','err'); return; }
attachWalletEvents();
log('Requesting wallet connection…');
var accs=await req('eth_requestAccounts'); account=accs[0];
await ensurePolygon();
await finishConnect();
log('Connected. Review the amount, then Join.','ok');
}catch(e){
var m=String((e&&e.message)||e);
if(/load failed|failed to fetch|network|timeout/i.test(m)){
log('Your wallet’s network connection hiccuped (this happens inside wallet apps sometimes) — tap <strong>Connect wallet</strong> once more and it usually goes right through.','err');
}else{
log('Connect failed: '+m,'err');
}
}
}
// Inside a wallet's in-app browser the wallet is often ALREADY connected —
// recognize it silently on load (eth_accounts never prompts) instead of
// greeting a connected user like a stranger. Skipped when multiple wallets
// are injected (desktop) so no picker UI ever pops uninvited.
async function autoDetect(){
try{
var list=window.RMCWallet.list();
var p=list.length===1?list[0].provider:(list.length===0?window.ethereum:null);
if(!p) return;
eth=p; attachWalletEvents();
var accs=await req('eth_accounts',[]);
if(!accs||!accs.length){ log('✅ Wallet detected in this browser — tap "Connect wallet" to continue.','ok'); return; }
account=accs[0];
var cid=await req('eth_chainId');
if(cid===POLYGON_HEX){
await finishConnect();
log('✅ Already connected as '+account.slice(0,6)+'…'+account.slice(-4)+'. Review the amount, then Join.','ok');
}else{
$('wallet').textContent=account.slice(0,6)+'…'+account.slice(-4);
$('connected').style.display='block';
log('Wallet connected ('+account.slice(0,6)+'…'+account.slice(-4)+') but on another network — tap "Connect wallet" to switch to Polygon and continue.','ok');
}
}catch(e){}
}
async function doJoin(){
try{
if(!sponsorId){ log('Still loading the current sponsor — one moment.','err'); await loadSponsor(); if(!sponsorId) return; }
await ensurePolygon();
var val=regValue();
var data=SEL_REGISTER+word(sponsorId)+word(2);
log('Joining under #'+sponsorId+', Premium — paying '+polStr(val)+' POL. Confirm in your wallet…');
$('joinBtn').disabled=true;
var tx=await req('eth_sendTransaction',[{from:account,to:CONTRACT,value:hexWei(val),data:data}]);
log('Submitted — waiting for confirmation… <a href="https://polygonscan.com/tx/'+tx+'" target="_blank" rel="noopener">view tx</a>','ok');
var rc=await waitReceipt(tx);
if(!rc){ log('Still pending. Once it mines, open your dashboard at /my.',''); $('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);
if(newId){
var reported=reportJoin(newId);
$('result').style.display='block';
$('resultId').textContent='#'+newId;
$('resultSub').innerHTML='Welcome to the team! Taking you to your position page…';
$('resultLink').href='/my/'+newId; $('resultLink').style.display='inline-flex';
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,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;
}
}catch(e){ log('Join failed / rejected: '+(e.message||e),'err'); $('joinBtn').disabled=false; }
}
function el_scroll(id){ var e=$(id); if(e&&e.scrollIntoView) e.scrollIntoView({behavior:'smooth',block:'center'}); }
// Auto-confirm the join server-side: records the submission (source + clickid
// attribution), fires the purchase event + BeMob postback, enqueues rotation
// joins, and alerts the team — same pipeline as the manual submit-ID form.
function reportJoin(newId){
try{
var nameEl=$('memberName');
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():'');
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){}
}
// Mobile browsers have no injected wallet — offer deep links that reopen
// THIS page (query string included, so ?ref= survives) inside the wallet
// app's own browser, where window.ethereum exists.
function showWalletHelp(){
var box=$('walletHelp'); if(!box) return;
var here=location.host+location.pathname+location.search;
var mm=$('mmDeep'); if(mm) mm.href='https://metamask.app.link/dapp/'+here;
var tw=$('twDeep'); if(tw) tw.href='https://link.trustwallet.com/open_url?coin_id=966&url='+encodeURIComponent(location.href);
// Fill the "type this in your wallet's browser" hint with the sponsor's
// real join path so SafePal/Coinbase/etc. users land on the right page.
var wb=$('wbId'); if(wb){ var ref=new URLSearchParams(location.search).get('ref'); wb.parentNode.textContent='rmcircle.team'+(ref&&/^\d{1,15}$/.test(ref)?'/join/'+ref:'/join-now'); }
box.style.display='block';
}
function attachWalletEvents(){
if(!eth||!eth.on||eth.__rmcBound) return; eth.__rmcBound=true;
eth.on('accountsChanged',function(accs){ account=(accs&&accs[0])||null; if(!account){ $('connected').style.display='none'; $('connectBtn').style.display=''; } else { $('wallet').textContent=account.slice(0,6)+'…'+account.slice(-4); refreshBalance(); } });
eth.on('chainChanged',function(){ if(account) refreshBalance(); });
}
document.addEventListener('DOMContentLoaded',function(){
loadSponsor();
// Telegram Mini App webview: no wallet can ever be injected here, so lead
// with the deep links immediately — the join finishes in the wallet app's
// own browser (same page, sponsor + attribution carried in the URL).
if(window.__rmcInTg){
showWalletHelp();
var hd=$('walletHelp')&&$('walletHelp').firstElementChild;
if(hd)hd.textContent='📱 One tap to finish in your wallet app.';
log('You’re in Telegram — joining happens in your wallet app’s secure browser. Tap your wallet above; this page reopens there with your sponsor already set.','ok');
}
$('connectBtn').addEventListener('click',connect);
$('joinBtn').addEventListener('click',doJoin);
var fb=$('fundBtn'); if(fb)fb.addEventListener('click',openMoonpay);
var fr=$('fundRefresh'); if(fr)fr.addEventListener('click',function(){ log('Checking your balance…'); refreshBalance(); });
setTimeout(autoDetect,350); // let EIP-6963 announcements land first
});
})();