Add admin-gated direct on-chain enrollment fallback (/direct-join)
Contingency tool: if the official RM Circle dApp ever goes down, positions can still be created/upgraded straight from the member's own wallet. Vanilla JS talks only to window.ethereum (CSP-safe, no external libs), builds register() [0x30de37e4, sponsorId+tier, value=base*1.05] and upgrade() [0xd55ec697, value=next-level cost] calls decoded from live txs, reads prices via getAllCosts() and position via getMember(address). Page lives outside public/ and is served only through a getSession-gated route, so it's admin-only. The contract is public and immutable, so this cements a company-domain-independent path to enroll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>Direct On-Chain Enrollment · RM Circle</title><link rel="stylesheet" href="/styles.css">
|
||||||
|
<style>
|
||||||
|
.dj-wrap{width:min(760px,calc(100% - 32px));margin:24px auto 70px}
|
||||||
|
.dj-card{background:var(--card,#0b1e30);border:1px solid var(--line);border-radius:16px;padding:20px 22px;margin:0 0 16px}
|
||||||
|
.dj-mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px;color:var(--gold);word-break:break-all}
|
||||||
|
.dj-row{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
|
||||||
|
.dj-kv{display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--line);font-size:14px}
|
||||||
|
.dj-kv:last-child{border-bottom:0}
|
||||||
|
.dj-kv b{color:var(--text)}
|
||||||
|
.dj-log{font-size:13px;line-height:1.5;padding:8px 10px;border-left:2px solid var(--line);margin:6px 0;color:var(--muted);background:#08192880}
|
||||||
|
.dj-log.ok{border-color:var(--ok);color:#cfeede}
|
||||||
|
.dj-log.err{border-color:var(--danger);color:#f3b7ac}
|
||||||
|
.dj-field{margin:0 0 12px}
|
||||||
|
.dj-field label{display:block;font-size:12px;color:#a7b8c6;text-transform:uppercase;letter-spacing:.7px;font-weight:800;margin:0 0 6px}
|
||||||
|
#logWrap{max-height:260px;overflow:auto}
|
||||||
|
.dj-hide{display:none}
|
||||||
|
</style></head>
|
||||||
|
<body class="admin-bg">
|
||||||
|
<header class="wrap nav"><a class="brand" href="/admin"><div class="brand-mark">RM</div><span><span>RM Circle</span><small>Contingency · On-Chain Enrollment</small></span></a><div class="nav-actions"><a class="btn btn-secondary btn-sm" href="/admin">← Admin</a></div></header>
|
||||||
|
|
||||||
|
<main class="dj-wrap">
|
||||||
|
<div class="callout warning"><strong>Contingency tool — for use only if the official RM Circle dApp is ever unavailable.</strong> This page builds the enrollment/upgrade transaction that <em>your own wallet</em> signs and pays, directly to the smart contract. It holds no funds and has no authority. If an amount is ever wrong the contract simply rejects it and you keep your POL. Normal joins should still go through the official dApp and the <a href="/start" style="color:var(--gold)">Getting Started page</a>.</div>
|
||||||
|
|
||||||
|
<div class="dj-card">
|
||||||
|
<h2 style="margin:0 0 10px;font-size:20px">The contract</h2>
|
||||||
|
<div class="dj-kv"><span>RM Circle (Polygon Mainnet, chain 137)</span></div>
|
||||||
|
<div class="dj-mono" style="margin:6px 0 8px">0x33BdAEEfd6d17D80aE53816c916dFb26c4fB2DAF</div>
|
||||||
|
<a class="btn btn-secondary btn-sm" href="https://polygonscan.com/address/0x33bdaeefd6d17d80ae53816c916dfb26c4fb2daf#code" target="_blank" rel="noopener">View verified code on Polygonscan ↗</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dj-card">
|
||||||
|
<h2 style="margin:0 0 10px;font-size:20px">1 · Connect your wallet</h2>
|
||||||
|
<button id="connectBtn" class="btn btn-primary">Connect wallet</button>
|
||||||
|
<div id="connected" class="dj-hide">
|
||||||
|
<div class="dj-kv"><span>Wallet</span><b id="wallet" class="dj-mono">—</b></div>
|
||||||
|
<div class="dj-kv"><span>Network</span><b>Polygon Mainnet ✓</b></div>
|
||||||
|
<div class="dj-kv"><span>Balance</span><b id="bal">—</b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="posBox" class="dj-card dj-hide">
|
||||||
|
<h2 style="margin:0 0 8px;font-size:20px">Your existing position</h2>
|
||||||
|
<p id="posInfo" style="color:var(--muted);font-size:14px;margin:0"></p>
|
||||||
|
<div id="upBox" class="dj-hide" style="margin-top:14px">
|
||||||
|
<div class="dj-kv"><span>Upgrade to</span><b id="upNext">—</b></div>
|
||||||
|
<div class="dj-kv"><span>Cost</span><b id="upCost">—</b></div>
|
||||||
|
<button id="upgradeBtn" class="btn btn-teal" style="margin-top:12px" data-tier="2" data-level="1">Upgrade my position →</button>
|
||||||
|
</div>
|
||||||
|
<p id="upInfo" style="color:var(--muted);font-size:14px;margin:8px 0 0"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dj-card">
|
||||||
|
<h2 style="margin:0 0 10px;font-size:20px">2 · Register a new position</h2>
|
||||||
|
<p style="color:var(--muted);font-size:14px;margin:0 0 14px">A wallet can hold one position. To create a new one, connect a fresh wallet funded with enough POL, enter the sponsor ID to join under, and register.</p>
|
||||||
|
<div class="dj-field"><label for="sponsorId">Sponsor ID (who you join under)</label><input id="sponsorId" class="input" inputmode="numeric" placeholder="e.g. 21"></div>
|
||||||
|
<div class="dj-field"><label for="tier">Tier</label><select id="tier" class="select"><option value="2">Premium (full — team standard)</option><option value="1">Standard (half)</option></select></div>
|
||||||
|
<div class="dj-kv"><span>You will pay (Premium)</span><b id="joinCost">—</b></div>
|
||||||
|
<button id="registerBtn" class="btn btn-primary" style="margin-top:14px">Register position →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dj-card">
|
||||||
|
<h2 style="margin:0 0 8px;font-size:16px">Advanced (optional)</h2>
|
||||||
|
<div class="dj-field"><label for="customPol">Override POL amount</label><input id="customPol" class="input" placeholder="leave blank to use the auto-calculated amount"></div>
|
||||||
|
<p style="color:var(--muted);font-size:12px;margin:0">Only use this if you need to send a specific amount. The auto amount is read live from the contract's own price table.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dj-card">
|
||||||
|
<h2 style="margin:0 0 8px;font-size:16px">Activity</h2>
|
||||||
|
<div id="logWrap"><div id="log"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="micro" style="text-align:center">No income is guaranteed. Cryptocurrency and smart-contract participation carry risk. Only use funds you can afford to lose.</p>
|
||||||
|
</main>
|
||||||
|
<script src="/direct-join.js"></script></body></html>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/* RM Circle — direct on-chain enrollment (contingency tool).
|
||||||
|
Talks ONLY to the injected wallet provider (window.ethereum), so it needs no
|
||||||
|
external libraries and no network of its own — CSP-safe (connect-src 'self').
|
||||||
|
It builds transactions to the RM Circle contract that the USER signs and pays;
|
||||||
|
this page has zero custody and zero authority. If msg.value is ever wrong the
|
||||||
|
contract simply reverts and the user keeps their POL (minus trivial gas). */
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
var CONTRACT='0x33bdaeefd6d17d80ae53816c916dfb26c4fb2daf';
|
||||||
|
var POLYGON_HEX='0x89'; // 137
|
||||||
|
// function selectors (decoded from live transactions)
|
||||||
|
var SEL_REGISTER='0x30de37e4'; // register(uint48 sponsorId, uint8 tier) payable
|
||||||
|
var SEL_UPGRADE ='0xd55ec697'; // upgrade() payable
|
||||||
|
var SEL_GETMEMBER='0x2ada2596';// getMember(address) view
|
||||||
|
var SEL_GETCOSTS='0x735f87b9'; // getAllCosts() view -> 4x uint256[8] (reg std, reg prem, up std, up prem)
|
||||||
|
var LEVELS=['','Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona'];
|
||||||
|
|
||||||
|
var $=function(id){return document.getElementById(id);};
|
||||||
|
var eth=window.ethereum;
|
||||||
|
var account=null, costs=null;
|
||||||
|
|
||||||
|
function log(msg,kind){var b=$('log');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');} // uint -> 32-byte hex word
|
||||||
|
function addrWord(a){return a.toLowerCase().replace(/^0x/,'').padStart(64,'0');}
|
||||||
|
function polStr(wei){return (Number(wei)/1e18).toFixed(4);}
|
||||||
|
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']); }
|
||||||
|
|
||||||
|
// ---- reads (via the wallet's own RPC) ----
|
||||||
|
async function loadCosts(){
|
||||||
|
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)));
|
||||||
|
// 4 fixed arrays of 8: [0-7] reg std, [8-15] reg prem, [16-23] up std, [24-31] up prem
|
||||||
|
costs={ regStd:w.slice(0,8), regPrem:w.slice(8,16), upStd:w.slice(16,24), upPrem:w.slice(24,32) };
|
||||||
|
return costs;
|
||||||
|
}
|
||||||
|
// registration price = base * 105/100 (5% on top — verified against live joins)
|
||||||
|
function regValue(tier){ var base=(tier===2?costs.regPrem:costs.regStd)[0]; return base*105n/100n; }
|
||||||
|
// upgrade price = the exact next-level cost (no add-on — verified against live upgrades)
|
||||||
|
function upValue(tier,level){ var arr=(tier===2?costs.upPrem:costs.upStd); return arr[level-1]; }
|
||||||
|
|
||||||
|
async function readPosition(addr){
|
||||||
|
try{
|
||||||
|
var r=await ethCall(SEL_GETMEMBER+addrWord(addr)); if(!r||r==='0x') return null;
|
||||||
|
var d=r.slice(2), tier=parseInt(d.slice(4*64,5*64),16), level=parseInt(d.slice(5*64,6*64),16);
|
||||||
|
var directs=parseInt(d.slice(6*64,7*64),16);
|
||||||
|
if(!level) return null; // unregistered
|
||||||
|
return {tier:tier,level:level,directs:directs};
|
||||||
|
}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){ // chain not added
|
||||||
|
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 refreshBalance(){
|
||||||
|
try{ var b=await req('eth_getBalance',[account,'latest']); $('bal').textContent=polStr(BigInt(b))+' POL'; }catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(){
|
||||||
|
if(!eth){ log('No wallet detected. Open this page in a browser with MetaMask (extension or the MetaMask in-app browser).','err'); return; }
|
||||||
|
try{
|
||||||
|
log('Requesting wallet connection…');
|
||||||
|
var accs=await req('eth_requestAccounts'); account=accs[0];
|
||||||
|
await ensurePolygon();
|
||||||
|
await loadCosts();
|
||||||
|
$('wallet').textContent=account.slice(0,6)+'…'+account.slice(-4);
|
||||||
|
$('connected').style.display='block'; $('connectBtn').style.display='none';
|
||||||
|
await refreshBalance();
|
||||||
|
// premium join cost preview
|
||||||
|
$('joinCost').textContent=polStr(regValue(2))+' POL';
|
||||||
|
var pos=await readPosition(account);
|
||||||
|
if(pos){
|
||||||
|
$('posBox').style.display='block';
|
||||||
|
$('posInfo').innerHTML='This wallet already holds a position: <strong>'+LEVELS[pos.level]+'</strong> (level '+pos.level+'/8, '+(pos.tier===2?'Premium':'Standard')+' tier, '+pos.directs+'/2 directs).';
|
||||||
|
if(pos.level<8){ $('upBox').style.display='block'; $('upNext').textContent=LEVELS[pos.level+1]; $('upCost').textContent=polStr(upValue(pos.tier,pos.level))+' POL'; $('upgradeBtn').dataset.tier=pos.tier; $('upgradeBtn').dataset.level=pos.level; }
|
||||||
|
else $('upInfo').innerHTML='Already at the top level (Corona).';
|
||||||
|
log('Connected. This wallet is registered — Upgrade is available; Join a new position would need a fresh wallet.','ok');
|
||||||
|
} else {
|
||||||
|
log('Connected. This wallet has no position yet — you can Register a new position below.','ok');
|
||||||
|
}
|
||||||
|
}catch(e){ log('Connect failed: '+(e.message||e),'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRegister(){
|
||||||
|
try{
|
||||||
|
var sponsor=($('sponsorId').value||'').trim();
|
||||||
|
if(!/^\d{1,15}$/.test(sponsor)){ log('Enter a valid numeric sponsor ID first.','err'); return; }
|
||||||
|
var tier=$('tier').value==='1'?1:2;
|
||||||
|
await ensurePolygon();
|
||||||
|
var val=regValue(tier);
|
||||||
|
var override=($('customPol').value||'').trim();
|
||||||
|
if(override){ if(!/^\d+(\.\d+)?$/.test(override)){log('Custom amount must be a number.','err');return;} val=BigInt(Math.round(parseFloat(override)*1e6))*(10n**12n); }
|
||||||
|
var data=SEL_REGISTER+word(sponsor)+word(tier);
|
||||||
|
log('Sending REGISTER — sponsor #'+sponsor+', '+(tier===2?'Premium':'Standard')+', paying '+polStr(val)+' POL. Confirm in your wallet…');
|
||||||
|
var tx=await req('eth_sendTransaction',[{from:account,to:CONTRACT,value:hexWei(val),data:data}]);
|
||||||
|
log('✅ Submitted: <a href="https://polygonscan.com/tx/'+tx+'" target="_blank" rel="noopener">'+tx.slice(0,18)+'…</a> — wait for it to confirm, then your position is live.','ok');
|
||||||
|
setTimeout(refreshBalance,4000);
|
||||||
|
}catch(e){ log('Register failed / rejected: '+(e.message||e),'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUpgrade(){
|
||||||
|
try{
|
||||||
|
await ensurePolygon();
|
||||||
|
var tier=parseInt($('upgradeBtn').dataset.tier,10), level=parseInt($('upgradeBtn').dataset.level,10);
|
||||||
|
var val=upValue(tier,level);
|
||||||
|
var override=($('customPol').value||'').trim();
|
||||||
|
if(override){ if(!/^\d+(\.\d+)?$/.test(override)){log('Custom amount must be a number.','err');return;} val=BigInt(Math.round(parseFloat(override)*1e6))*(10n**12n); }
|
||||||
|
var data=SEL_UPGRADE;
|
||||||
|
log('Sending UPGRADE '+LEVELS[level]+' → '+LEVELS[level+1]+', paying '+polStr(val)+' POL. Confirm in your wallet…');
|
||||||
|
var tx=await req('eth_sendTransaction',[{from:account,to:CONTRACT,value:hexWei(val),data:data}]);
|
||||||
|
log('✅ Submitted: <a href="https://polygonscan.com/tx/'+tx+'" target="_blank" rel="noopener">'+tx.slice(0,18)+'…</a>','ok');
|
||||||
|
setTimeout(refreshBalance,4000);
|
||||||
|
}catch(e){ log('Upgrade failed / rejected: '+(e.message||e),'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded',function(){
|
||||||
|
$('connectBtn').addEventListener('click',connect);
|
||||||
|
$('registerBtn').addEventListener('click',doRegister);
|
||||||
|
var ub=$('upgradeBtn'); if(ub) ub.addEventListener('click',doUpgrade);
|
||||||
|
if(eth && eth.on){ eth.on('accountsChanged',function(){location.reload();}); eth.on('chainChanged',function(){location.reload();}); }
|
||||||
|
if(!eth) log('No wallet detected in this browser. Use MetaMask (desktop extension) or the MetaMask in-app browser on mobile.','err');
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -619,7 +619,7 @@ const server=http.createServer(async(req,res)=>{
|
|||||||
if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]);
|
if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]);
|
||||||
}
|
}
|
||||||
let file;
|
let file;
|
||||||
if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/start'});return res.end();}else{
|
if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/start'});return res.end();}else{
|
||||||
const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file='';
|
const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file='';
|
||||||
}
|
}
|
||||||
if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404);
|
if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404);
|
||||||
|
|||||||
Reference in New Issue
Block a user