// bot.ts: quote, open and close one position on Rekt Knockout.
//
// npx tsx bot.ts dry run: reads the chain, quotes, simulates, sends nothing
// SEND=1 npx tsx bot.ts sends: opens, waits for the fill, closes, prints the payout
//
// Set SOLANA_RPC_URL (an RPC endpoint) and KEYPAIR_PATH (a Solana keypair file). With SEND=1 this
// spends real USDC, so start with a small STAKE_USDC.
import { readFileSync } from 'node:fs';
import {
ComputeBudgetProgram,
Connection,
Keypair,
PublicKey,
TransactionMessage,
VersionedTransaction,
} from '@solana/web3.js';
import {
assetId,
chainNowSeconds,
closableFromSlotOf,
computeUnitLimit,
configPda,
connectionReader,
decodeConfig,
decodeMarket,
decodeTraderAccount,
decodeVault,
errorForCode,
findBySeq,
ix,
landedOpenedSeq,
marketPda,
openPreview,
ourInstruction,
parseUsdc,
priceAccountsFor,
programErrorCode,
readSettlementForSeq,
sizeForTotal,
toHex,
traderPda,
vaultPda,
type BuiltInstruction,
} from '@rekt/1000x';
const SYMBOL = process.env['MARKET'] ?? 'BTC';
const BUDGET = parseUsdc(process.env['STAKE_USDC'] ?? '1.00'); // the most that leaves your wallet
const RUNG_BPS = 20_000; // a 2.00x target. Basis points of stake: 20_000 is the lowest target.
const SEND = process.env['SEND'] === '1';
const url = process.env['SOLANA_RPC_URL'];
const keypairPath = process.env['KEYPAIR_PATH'];
if (!url || !keypairPath) throw new Error('set SOLANA_RPC_URL and KEYPAIR_PATH');
const connection = new Connection(url, 'confirmed');
const wallet = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(readFileSync(keypairPath, 'utf8'))));
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const usd = (n: bigint) => `$${n / 1_000_000n}.${(n % 1_000_000n).toString().padStart(6, '0').slice(0, 2)}`;
async function read(address: PublicKey): Promise<Uint8Array | null> {
const info = await connection.getAccountInfo(address, 'confirmed');
return info === null ? null : new Uint8Array(info.data);
}
async function must(address: PublicKey, what: string): Promise<Uint8Array> {
const bytes = await read(address);
if (bytes === null) throw new Error(`${what} does not exist at ${address.toBase58()}`);
return bytes;
}
/** Ask `probe` until it answers, for at most `ms`. */
async function until<T>(what: string, ms: number, probe: () => Promise<T | null>): Promise<T> {
const deadline = Date.now() + ms;
for (;;) {
const answer = await probe();
if (answer !== null) return answer;
if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
await sleep(500);
}
}
/** Build, simulate and (with SEND=1) send one transaction. A refusal is reported by name. */
async function run(label: string, built: BuiltInstruction[], units: number): Promise<string | null> {
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed');
const message = new TransactionMessage({
payerKey: wallet.publicKey,
recentBlockhash: blockhash,
instructions: [
ComputeBudgetProgram.setComputeUnitLimit({ units }),
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1_000 }),
...built.map(ourInstruction),
],
}).compileToV0Message();
const tx = new VersionedTransaction(message);
tx.sign([wallet]);
const sim = await connection.simulateTransaction(tx, { commitment: 'confirmed' });
if (sim.value.err !== null) {
const refusal = errorForCode(programErrorCode(sim.value.err) ?? -1);
// `retryable` says what to do next: `never` means fix the request, `after-state-changes` means wait.
throw new Error(
`${label} refused: ${refusal ? `${refusal.name} (${refusal.retryable}) ${refusal.fix}` : JSON.stringify(sim.value.err)}`,
);
}
if (!SEND) {
console.log(`${label}: simulated, ${sim.value.unitsConsumed} compute units. Dry run: nothing sent.`);
return null;
}
const signature = await connection.sendTransaction(tx);
const done = await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, 'confirmed');
if (done.value.err !== null) throw new Error(`${label} reverted: ${signature}`);
console.log(`${label}: landed ${signature}`);
return signature;
}
async function main() {
// 1. Read the protocol. Never cache it: every parameter is governed.
const market = decodeMarket(await must(marketPda(assetId(SYMBOL)).address, `the ${SYMBOL} market`));
const config = decodeConfig(await must(configPda().address, 'the config'));
const vault = decodeVault(await must(vaultPda().address, 'the vault'));
const nowUnix = BigInt(await chainNowSeconds(connection));
const vaultToken = new PublicKey(Buffer.from(vault.tokenAccount, 'hex'));
// 2. Quote. `sizeForTotal` turns a budget into a stake. `openPreview` says what it costs and pays.
const size = sizeForTotal(BUDGET, config.params);
if (size.refusal) throw new Error(`${size.refusal.error}: ${size.refusal.detail}`);
const quote = openPreview(config, market, size.collateral, RUNG_BPS, nowUnix);
if (quote.barrierPayout === null) throw new Error('the engine refuses these terms');
console.log(
`quote ${SYMBOL}: stake ${usd(size.collateral)}, you pay ${usd(quote.totalDebit)}, ` +
`gross at the target ${usd(quote.maxPayout)} (before the exit fee and the share of the gain the terms withhold), ` +
`at the barrier ${usd(quote.barrierPayout)}`,
);
// 3. Open. Your USDC account is your wallet's associated token account on the vault's mint.
const mint = new PublicKey((await must(vaultToken, 'the vault token account')).subarray(0, 32));
const [usdc] = PublicKey.findProgramAddressSync(
[wallet.publicKey.toBytes(), new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA').toBytes(), mint.toBytes()],
new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'),
);
await must(usdc, 'your USDC account');
const book = traderPda(wallet.publicKey).address; // one account holds all your positions
const me = toHex(wallet.publicKey); // the program takes addresses as hex
const accounts = {
config: toHex(configPda().address),
market: toHex(marketPda(assetId(SYMBOL)).address),
vault: toHex(vaultPda().address),
vault_token: toHex(vaultToken),
positions: toHex(book),
};
const open = ix.open(
{
authority: me,
trader_token: toHex(usdc),
config: accounts.config,
market: accounts.market,
vault: accounts.vault,
vault_token: accounts.vault_token,
protocol_token: config.treasury,
positions: accounts.positions,
delegate: null,
},
{
budget: BUDGET,
direction: 0, // 0 long, 1 short
rung_bps: RUNG_BPS,
user_timer_s: 0, // no timer of your own
min_collateral_out: size.collateral, // the smallest stake you accept for this budget
referrer: null,
tier: 0, // 0 is the market's own leverage
opener_tag: 0n,
},
priceAccountsFor(market, config, 'open'), // adds the market's fee-terms account when it has one
);
// A first trade also creates the book, in the same transaction.
const first = (await read(book)) === null;
const createBook = first
? [
ix.initTraderAccount(
{
trader: me,
payer: me,
positions: accounts.positions,
vault: accounts.vault,
vault_token: accounts.vault_token,
payout_destination: toHex(usdc), // every payout of this book goes here
},
4, // room for four positions at once; 1 to 12
),
]
: [];
const units =
computeUnitLimit('open', { quotePost: false }) +
(first ? computeUnitLimit('init_trader_account', { quotePost: false }) : 0);
const signature = await run('open', [...createBook, open], units);
if (signature === null) return; // a dry run stops here
// 4. The open is an order. The venue's next price fills it, or returns it with your whole stake.
const landed = await connection.getTransaction(signature, { commitment: 'confirmed', maxSupportedTransactionVersion: 0 });
const opened = landedOpenedSeq({
logs: landed?.meta?.logMessages ?? null,
returnData: null,
owner: me,
market: accounts.market,
});
if (opened.seq === null) throw new Error(opened.why);
const seq = opened.seq; // the handle of your position; never use the slot index
const find = async () => {
const bytes = await read(book);
return bytes === null ? null : findBySeq(decodeTraderAccount(bytes), seq);
};
const filled = await until('the fill', 60_000, async () => {
const position = await find();
if (position === null) return 'returned' as const; // the order was returned
return position.entryPrice !== 0n ? position : null;
});
if (filled === 'returned') return console.log('the order was returned: your whole stake came back');
console.log(`filled at ${filled.entryPrice}`);
// 5. Close. A close is a request, and the venue's keepers complete it. You cannot ask in the first
// slots after the entry; the SDK says which slot is the first.
const from = closableFromSlotOf(filled, config, market);
while (from !== null && BigInt(await connection.getSlot('confirmed')) < from) await sleep(400);
await run(
'close',
[
ix.requestClose(
{ authority: me, config: accounts.config, market: accounts.market, positions: accounts.positions, delegate: null },
seq,
),
],
computeUnitLimit('request_close', { quotePost: false }),
);
await until('the settlement', 120_000, async () => ((await find()) === null ? true : null));
// 6. Read what the close paid. Part of a win can be paid later: see "how a win is paid".
const [trade] = await readSettlementForSeq(connectionReader(connection), wallet.publicKey, [seq], config);
console.log(trade ? `closed (${trade.reason}): paid ${usd(trade.payouts.trader)} now` : 'closed: read the settlement from your history');
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});