229 lines
13 KiB
HTML
229 lines
13 KiB
HTML
<!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> |