import { keccak256, getBytes, concat, toUtf8Bytes, zeroPadValue, toBeArray, hexlify, SigningKey, Wallet } from "ethers";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
const BASE_URL = "https://upsidemax.xyz"; // Mainnet; Devnet is https://dev.upsidemax.xyz
const CHAIN_ID = 9788; // Mainnet; Devnet is 9767
const INVITE_CODE = "<your-invite-code>"; // from the UpsideMAX team
const SYMBOL: string | null = null; // a contract `name` from configs, or null for the first Active one
const MARKET_DEPLOYER = 1;
const KEY_FILE = ".upside-key"; // reuse one wallet: an invite code is single-use; keep this file private
// Reuse a saved wallet if there is one, so re-running never burns another code.
const PRIVATE_KEY = existsSync(KEY_FILE)
? readFileSync(KEY_FILE, "utf8").trim()
: (() => { const k = Wallet.createRandom().privateKey; writeFileSync(KEY_FILE, k, { mode: 0o600 }); return k; })();
const ADDRESS = new Wallet(PRIVATE_KEY).address.toLowerCase();
// --- EIP-712 signer (domain: Exchange / v1 / CHAIN_ID / verifyingContract 0x0) ---
const kb = (d: Uint8Array) => getBytes(keccak256(d));
const u = (v: bigint | number) => getBytes(zeroPadValue(toBeArray(BigInt(v)), 32));
const hs = (s: string) => kb(toUtf8Bytes(s));
const cat = (a: Uint8Array[]) => getBytes(concat(a));
const DOMAIN = kb(cat([hs("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), hs("Exchange"), hs("1"), u(CHAIN_ID), u(0)]));
const TYPED: Record<string, (a: any, n: bigint) => Uint8Array[]> = { // full set → Authentication
registerAccount: (a, n) => [hs("RegisterAccount(address address,uint64 nonce)"), getBytes(zeroPadValue(a.address, 32)), u(n)],
lockCollateral: (a, n) => [hs("LockCollateral(uint32 marketDeployerId,uint32 coinId,string amount,uint64 nonce)"), u(a.marketDeployerId), u(a.coinId), hs(String(a.amount)), u(n)],
};
const canon = (o: any): string =>
Array.isArray(o) ? "[" + o.map(canon).join(",") + "]"
: o && typeof o === "object" ? "{" + Object.keys(o).sort().map(k => JSON.stringify(k) + ":" + canon(o[k])).join(",") + "}"
: JSON.stringify(o);
function digest(action: any, n: bigint): Uint8Array {
let struct: Uint8Array;
if (TYPED[action.type]) struct = kb(cat(TYPED[action.type](action, n)));
else { // Agent path
const h = kb(cat([toUtf8Bytes(canon(action)), getBytes(zeroPadValue(toBeArray(n), 8))]));
struct = kb(cat([hs("Agent(string source,bytes32 actionHash)"), hs("b"), h]));
}
return kb(cat([Uint8Array.from([0x19, 0x01]), DOMAIN, struct]));
}
let lastNonce = 0;
const nextNonce = () => (lastNonce = Math.max(lastNonce + 1, Date.now())); // strictly increasing
async function signAndSend(action: any, extra: Record<string, unknown> = {}) {
const n = BigInt(nextNonce());
const sig = new SigningKey(PRIVATE_KEY).sign(hexlify(digest(action, n)));
return (await fetch(`${BASE_URL}/exchange`, { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, nonce: Number(n), signature: { r: sig.r, s: sig.s, v: sig.v }, ...extra }) })).json();
}
const info = (q: any) => fetch(`${BASE_URL}/info`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(q) }).then(r => r.json());
(async () => {
// 1. Resolve the contract. Filter to status "Active": delisted contracts are returned
// too, with a full parameter set and nothing else to mark them.
const CFG = await info({ type: "configs" });
const live = (CFG.contracts as any[])
.filter(c => c.status === "Active" && (SYMBOL === null || c.name === SYMBOL));
if (live.length === 0) throw new Error(`no Active contract matching ${SYMBOL} — stop, do not substitute`);
const C = live[0], USDC = C.quoteCoinId;
console.log("contract", C.name, C.contractId);
// 2. Register only if this wallet is new. accountByAddress returns "0" for an
// unregistered address, so a re-run reuses the account instead of spending
// another single-use code.
let acct = (await info({ type: "accountByAddress", address: ADDRESS })).accountId as string;
if (acct === "0") {
acct = (await signAndSend({ type: "registerAccount", address: ADDRESS }, { inviteCode: INVITE_CODE })).response.accountId;
console.log("registered", acct);
} else {
console.log("reusing account", acct);
}
// 3. Make sure the account has usable margin. A new account starts empty. A chain-level
// balance is not margin until lockCollateral moves it into the market deployer.
const marginAvail = async () =>
BigInt((await info({ type: "userAccount", accountId: String(acct), marketDeployerId: MARKET_DEPLOYER })).marginAvailableForOrder ?? "0");
const chainUsdc = async () => {
const bals = (await info({ type: "userAccount", accountId: String(acct), marketDeployerId: 0 })).chainBalances ?? [];
const c = bals.find((x: any) => x.coinId === USDC);
return c ? BigInt(c.amount) : 0n;
};
if ((await marginAvail()) === 0n) {
const chain = await chainUsdc();
if (chain > 0n) await signAndSend({ type: "lockCollateral", marketDeployerId: MARKET_DEPLOYER, coinId: USDC, amount: String(chain) });
for (let i = 0; i < 10 && (await marginAvail()) === 0n; i++) await new Promise(r => setTimeout(r, 1000));
}
if ((await marginAvail()) === 0n) throw new Error(`account ${acct} has no usable margin — deposit funds, then re-run (the account is reused)`);
// 4. Price off the live mark. A limit price further from mark than the contract's
// priceBandBps is rejected, so never invent one. Integer maths only — no floats.
const snap = (raw: bigint, increment: string, name: string) => {
const inc = BigInt(increment);
const out = raw - (raw % inc); // snap down to tick / step
if (out <= 0n) throw new Error(`${name} rounded to zero against increment ${inc} — nothing to send`);
return out;
};
const markRaw = BigInt((await info({ type: "marketState", asset: String(C.contractId) })).markPx);
if (markRaw <= 0n) throw new Error("no mark price published for this contract yet; priceReady is false");
const px = snap(markRaw * 80n / 100n, C.tickSize, "price"); // 20% below mark: rests, won't fill
// Derive the smallest size that clears the top-level minNotionalValue rather than hardcoding
// one: 0.01 is unrepresentable on a contract whose qtyScale is 0.
// notional = price x size x notionalScaleMultiplier, all raw integers.
const mult = BigInt(C.notionalScaleMultiplier), step = BigInt(C.stepSize);
const ceil = (a: bigint, b: bigint) => (a + b - 1n) / b;
let need = ceil(BigInt(CFG.minNotionalValue), px * mult);
if (need === 0n) need = 1n;
const sz = ceil(need, step) * step; // round up to a whole step
console.log(`mark=${markRaw} -> limit p=${px} s=${sz}`);
// 5. Place the order. A 202 means "accepted for processing" — NOT that the order rested.
// Confirm by polling; a rejection never appears here, only on the orderUpdates channel.
const cloid = String(nextNonce());
console.log(await signAndSend({ type: "order", grouping: "na",
orders: [{ a: C.contractId, b: true, p: px.toString(), s: sz.toString(), r: false, c: cloid, t: { limit: { tif: "Gtc" } } }] }));
let oid: number | undefined;
for (let i = 0; i < 15 && oid === undefined; i++) {
const orders = (await info({ type: "userOrders", accountId: String(acct), marketDeployerId: MARKET_DEPLOYER, contractId: C.contractId })).orders as any[];
oid = orders.find(o => o.clientOrderId === cloid)?.id;
if (oid === undefined) await new Promise(r => setTimeout(r, 1000));
}
if (oid === undefined) throw new Error("the order never rested — it was rejected. Subscribe to orderUpdates for the reason.");
// 6. Verify, then cancel
console.log(await info({ type: "userOrders", accountId: String(acct), marketDeployerId: MARKET_DEPLOYER }));
console.log(await signAndSend({ type: "cancel", cancels: [{ a: C.contractId, o: Number(oid) }] }));
})();