Технический тестовый фронт экономики Binary Rocket: подключение Phantom, балансы (фантики+SOL), swap пополнение/вывод через /api/pump/swap, ставка вверх/вниз через контракт с поллингом

This commit is contained in:
Caffeine
2026-09-09 18:36:15 +03:00
commit 41acef2238
2 changed files with 273 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<title>Binary Rocket — техтест экономики (фантики)</title>
<style>
body{font-family:monospace;background:#111;color:#eee;margin:20px;font-size:14px}
h1{font-size:16px}
.row{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0;align-items:center}
button{background:#333;color:#fff;border:1px solid #555;padding:6px 10px;cursor:pointer;font-family:monospace}
button:disabled{opacity:.4;cursor:not-allowed}
input,select{background:#222;color:#eee;border:1px solid #555;padding:5px;font-family:monospace}
label{margin-right:2px}
#log{background:#000;border:1px solid #444;padding:10px;height:320px;overflow:auto;white-space:pre-wrap;font-size:12px;line-height:1.4}
.ok{color:#7f7} .err{color:#f77} .info{color:#88f}
fieldset{border:1px solid #444;margin:10px 0;padding:10px}
legend{font-size:13px;color:#aaa}
.bal{font-size:15px;font-weight:bold}
</style>
</head>
<body>
<h1>Binary Rocket :: tech test (фантики)</h1>
<fieldset>
<legend>Конфигурация</legend>
<div class="row">
<label>API base</label><input id="apiBase" size="42" value="https://smart-updown.binom.pw"/>
<label>RPC</label><input id="rpcUrl" size="34" value="https://rpc.solanatracker.io/public"/>
<label>Program</label><input id="programId" size="30" value="FMMDsmUKh5e4kYsofVA2jtzHGuY2kCfoh2NmMWSZr2f"/>
<label>Token mint</label><input id="tokenMint" size="30" value=""/>
</div>
<div class="row">
<button onclick="connect()">Подключить Phantom</button>
<span id="walletInfo">—</span>
<button onclick="refreshBalances()">Обновить баланс</button>
<button onclick="copyLog()">Копировать лог</button>
<button onclick="log.clear()">Очистить лог</button>
</div>
</fieldset>
<fieldset>
<legend>Валюта / балансы</legend>
<div class="row bal">
<span>Фантики: <span id="balTok">—</span></span>
<span style="margin-left:18px">SOL: <span id="balSol">—</span></span>
</div>
</fieldset>
<fieldset>
<legend>Пополнить / Вывести (swap pump.fun)</legend>
<div class="row">
<label>SOL (пополнить)</label><input id="depSol" size="10" value="0.1"/>
<button onclick="deposit()">Пополнить фантики</button>
</div>
<div class="row">
<label>Фантики (вывести)</label><input id="wdTok" size="10" value="100000"/>
<button onclick="withdraw()">Вывести в SOL</button>
</div>
</fieldset>
<fieldset>
<legend>Ставка (через контракт, валюта = фантики, исход = цена SOL)</legend>
<div class="row">
<label>Сумма (фантики)</label><input id="betAmt" size="12" value="100000"/>
<label>Сторона</label>
<select id="betSide"><option value="UP">UP (вверх)</option><option value="DOWN">DOWN (вниз)</option></select>
<button onclick="placeBet()">Сделать ставку</button>
</div>
<div id="stateInfo" class="info" style="margin-top:6px">Состояние ещё не прочитано.</div>
</fieldset>
<fieldset>
<legend>Лог</legend>
<div id="log"></div>
</fieldset>
<script src="https://cdn.jsdelivr.net/npm/@solana/web3.js@1.98.0/lib/index.iife.js"></script>
<script>
// ---------- tech-test front: Binary Rocket economics ----------
const log = (() => {
const el = () => document.getElementById('log');
const line = (cls, text) => { const e=el(); const d=document.createElement('div'); d.className=cls; d.textContent='['+new Date().toLocaleTimeString()+'] '+text; e.appendChild(d); e.scrollTop=e.scrollHeight; };
return { ok:(t)=>line('ok',t), err:(t)=>line('err',t), info:(t)=>line('info',t), clear:()=>el().textContent='' };
})();
const cfg = () => ({
apiBase: document.getElementById('apiBase').value.replace(/\/+$/,''),
rpcUrl: document.getElementById('rpcUrl').value,
programId:new web3.PublicKey(document.getElementById('programId').value),
tokenMint:document.getElementById('tokenMint').value.trim(),
});
const NATIVE_MINT='So11111111111111111111111111111111111111112';
let provider=null, pubkey=null, cache={}; // cache: programId, mint, decimals, conn, globalPda, betPda
const logUsr=(m)=>{document.getElementById('walletInfo').textContent=m;};
const getProvider=()=> new Promise((res,rej)=>{
const w=window; if(w.solana&&w.solana.isPhantom) return res(w.solana);
const dl=Date.now()+10000; const t=setInterval(()=>{ if(w.solana&&w.solana.isPhantom){clearInterval(t);res(w.solana);} else if(Date.now()>dl){clearInterval(t);rej(new Error('Phantom не найден'));} },150);
});
async function connect(){
try{ provider=await getProvider(); await provider.connect(); pubkey=provider.publicKey;
const key=pubkey?String(pubkey.toString()):''; logUsr(key? 'Подключён: '+key : 'нет ключа'); log.ok('Кошелёк подключён: '+key);
if(cfg().tokenMint) await refreshBalances();
}catch(e){log.err('connect: '+e.message);}
}
// returns {conn, mint, decimals, globalPda, betPda}
async function buildCtx(){
if(cache.conn) return cache;
const c=cfg();
cache.conn=new web3.Connection(c.rpcUrl,'confirmed');
cache.mint=new web3.PublicKey(c.tokenMint);
const acc=await cache.conn.getAccountInfo(cache.mint);
if(!acc) throw new Error('Не найден mint-аккаунт токена: '+c.tokenMint);
cache.decimals=ccna(acc);// TODO: читать из mint(метаданные/еxtensions) — для pump Token-2022 decimals в account data
if(!cache.decimals) cache.decimals=6; // pump default
cache.globalPda=web3.PublicKey.findProgramAddressSync([new TextEncoder().encode('global')], c.programId)[0];
cache.betPda=(id)=>web3.PublicKey.findProgramAddressSync([new TextEncoder().encode('bet'), leU64(id)], c.programId)[0];
return cache;
}
function ccna(acc){ // parse decimals from mint account data — SPL/Token22: u8(0..) mint_authority(32) supply(8) options(1) decimals(1)
try{ const d=acc.data.subarray(44,45).readUInt8(0); return d; }catch(e){ return 6; } }
function leU64(v){ const b=new Uint8Array(8); let n=BigInt(String(v)); for(let i=0;i<8;i++){b[i]=Number(n&BigInt(0xff)); n>>=BigInt(8);} return b; }
const aBuf=(bytes)=>{let x='';for(let i=0;i<bytes.length;i++)x+=String.fromCharCode(bytes[i]);return btoa(x);};
async function refreshBalances(){
try{ if(!pubkey){log.err('Сначала подключи кошелёк');return;}
const c=cfg(); if(!c.tokenMint){log.err('Укажи token mint');return;}
const ctx=await buildCtx();
// SOL balance
const sol=await ctx.conn.getBalance(pubkey);
document.getElementById('balSol').textContent=(sol/1e9).toFixed(6)+' SOL';
// token balance via ATA
const ata=web3.PublicKey.findProgramAddressSync([pubkey.toBuffer(), web3.TokenProgram.programId.toBuffer(), ctx.mint.toBuffer()], web3.ASSOCIATED_TOKEN_PROGRAM_ID)[0];
let tok='0';
try{ const ai=await ctx.conn.getAccountInfo(ata); if(ai){ const raw=ai.data; /* u64 at offset 64 */ const v=raw.readBigUInt64LE(64); tok=Number(v)/Math.pow(10,ctx.decimals); } }
catch(e){ log.warn?.('token bal: '+e.message); }
document.getElementById('balTok').textContent=tok.toLocaleString('ru');
log.info('Балансы обновлены: SOL='+sol+' lamports, токен='+tok);
}catch(e){log.err('refreshBalances: '+e.message);}
}
async function http(method, path, body){
const c=cfg(); const url=c.apiBase+path;
const r=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
const text=await r.text(); let j=null; try{ j=JSON.parse(text);}catch(e){}
if(!r.ok) throw new Error('HTTP '+r.status+' '+text);
return j;
}
async function swap(inputMint, outputMint, amount){ // returns base64 tx
if(!pubkey) throw new Error('no wallet');
const j=await http('POST','/api/pump/swap',{inputMint,outputMint,amount,user:String(pubkey.toString()),slippagePct:2,frontRunningProtection:false,encoding:'base64'});
return j.transaction;
}
async function signSend(txBase64){
const tx=web3.VersionedTransaction.deserialize(base64ToBytes(txBase64));
const signed=await provider.signTransaction(tx);
const c=cfg(); const sig=await c.rpc.sendRawTransaction(signed.serialize());
return sig;
}
function base64ToBytes(b64){ const bin=atob(b64); const u=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i); return u.buffer; }
async function deposit(){
try{ if(!pubkey){log.err('нет кошелька');return;} const c=cfg(); if(!c.tokenMint){log.err('нет token mint');return;}
const sol=parseFloat(document.getElementById('depSol').value); if(!(sol>0)){log.err('некорр SOL');return;}
const lamports=Math.round(sol*1e9); log.info('Пополнение: '+sol+' SOL → фантики...');
const txBase64=await swap(NATIVE_MINT, c.tokenMint, String(lamports));
const sig=await signSend(txBase64); log.ok('SWAP отправлен, sig='+sig);
await new Promise(r=>setTimeout(r,3000)); await refreshBalances();
}catch(e){log.err('deposit: '+e.message);}
}
async function withdraw(){
try{ if(!pubkey){log.err('нет кошелька');return;} const c=cfg(); if(!c.tokenMint){log.err('нет token mint');return;}
const ctx=await buildCtx();
const amt=document.getElementById('wdTok').value; const units=BigInt(amt)*BigInt(Math.pow(10,ctx.decimals));
log.info('Вывод: '+amt+' фантиков → SOL...');
const txBase64=await swap(c.tokenMint, NATIVE_MINT, String(units));
const sig=await signSend(txBase64); log.ok('SWAP отправлен, sig='+sig);
await new Promise(r=>setTimeout(r,3000)); await refreshBalances();
}catch(e){log.err('withdraw: '+e.message);}
}
let CREATE_BET_DISC=[0xc5,0x2a,0x99,0x02,0x3b,0x3f,0x8f,0xf6];
async function placeBet(){
try{ if(!pubkey){log.err('нет кошелька');return;} const c=cfg(); if(!c.tokenMint){log.err('нет token mint');return;}
// 1) state
log.info('Читаем /api/state...');
const st=await http('GET','/api/state'); document.getElementById('stateInfo').textContent=JSON.stringify(st);
const ctx=await buildCtx();
const side=document.getElementById('betSide').value==='UP'?0:1;
const amt=BigInt(document.getElementById('betAmt').value)*BigInt(Math.pow(10,ctx.decimals));
if(amt<st.minAmountLamports) log.warn?.('сумма ниже min — контракт отклонит');
// 2) build create_bet instruction (TOKEN variant — TODO: подтвердить ключи под токен-программу)
const ata=web3.PublicKey.findProgramAddressSync([pubkey.toBuffer(), web3.TokenProgram.programId.toBuffer(), ctx.mint.toBuffer()], web3.ASSOCIATED_TOKEN_PROGRAM_ID)[0];
const keys=[
{pubkey,isSigner:true,isWritable:true}, // 0 bettor
{pubkey:ctx.globalPda,isSigner:false,isWritable:true},
{pubkey:ctx.betPda(st.nextBetId),isSigner:false,isWritable:true},
{pubkey:new web3.PublicKey('SysvarC1ock11111111111111111111111111111111'),isSigner:false,isWritable:false},
{pubkey:new web3.PublicKey('11111111111111111111111111111111'),isSigner:false,isWritable:false},
// NOTE: при переводе на токен эти ключи изменятся (mint, ata ваулт, token program) — заглушка
];
const data=new Uint8Array(8+4*8); data.set(CREATE_BET_DISC,0); data.set(leU64(st.nextBetId),8); data.set(leU64(side),16); data.set(leU64(amt),24); data.set(leU64(st.entryPrice),32);
const ix=new web3.TransactionInstruction({programId:c.programId,keys,data});
const blockhash=await ctx.conn.getLatestBlockhash().then(r=>r.blockhash);
const tx=new web3.Transaction({feePayer:pubkey,recentBlockhash:blockhash}); tx.add(ix);
log.info('Подтверди ставку в Phantom (сумма '+amt+' units)...');
const signed=await provider.signTransaction(tx);
const signedB64=aBuf(signed.serialize());
log.info('POST /api/bet...');
const created=await http('POST','/api/bet',{side:side===0?'UP':'DOWN',amount:amt.toString(),entryPrice:st.entryPrice,betId:st.nextBetId,signedTx:signedB64});
log.ok('Ставка принята id='+created.id+' status='+created.status);
// 3) poll
for(let i=0;i<40;i++){ await new Promise(r=>setTimeout(r,1500));
const r=await http('GET','/api/bet/'+created.id);
if(r.status==='SETTLED'||r.status==='FAILED'){ log.ok('Раунд закрыт: '+r.status+' userWon='+r.userWon+' payout='+r.payout); await refreshBalances(); return; }
log.info('poll '+i+': '+r.status); }
log.err('poll таймаут');
}catch(e){log.err('placeBet: '+e.message);}
}
function copyLog(){ const t=document.getElementById('log').innerText; navigator.clipboard?.writeText(t).then(()=>log.ok('лог скопирован')).catch(()=>{}); }
</script>
</body>
</html>