Pour les développeurs / SENDIT

Construire un bot

Tout ce que fait l'application est un appel de contrat que vous pouvez faire vous-même. Lisez le manifeste, alimentez la banque, et envoyez depuis un portefeuille ou une clé enregistrée.

Portefeuille ou clé

Chaque venue résout son appelant via bank.accountOf, et un portefeuille se résout vers lui-même : un bot peut donc trader directement depuis un portefeuille dont le pUSD est dans la banque. Enregistrez une clé quand le bot ne doit pas détenir le portefeuille : la clé peut trader, envoyer le solde de la banque et retirer vers le portefeuille, rien d'autre.

Mise en place

viem
import { createPublicClient, createWalletClient, defineChain, http, parseAbi, parseEther, parseUnits } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const pickle = defineChain({
  id: 78270,
  name: "Pickle testnet",
  nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
  rpcUrls: { default: { http: ["https://rpc.picklechain.xyz"] } },
});
const ORIGIN = "https://sendit.picklechain.xyz";

const m = await fetch(`${ORIGIN}/op-deployment/l2-perps.json`).then((r) => r.json());
const client = createPublicClient({ chain: pickle, transport: http() });

const bankAbi = parseAbi([
  "function deposit(uint256 pusdAmount, uint256 gasToKey, address key, bytes keyProof) payable",
  "function setKey(address key, bytes keyProof)",
  "function keyDigest(address account, address key) view returns (bytes32)",
  "function accountOf(address sender) view returns (address)",
  "function balanceOf(address account, address asset) view returns (uint256)",
  "function withdraw(address asset, uint256 amount)",
]);
const perpsAbi = parseAbi([
  "struct OpenArgs { bytes32 feedId; bool isLong; uint256 margin; uint16 leverage; uint256 tp; uint256 sl; }",
  "function open(OpenArgs a) returns (uint256 id)",
  "function close(uint256 id)",
  "function isImmediate(bytes32 feedId) view returns (bool)",
  "function whyNotOpen(address trader, OpenArgs a) view returns (string)",
  "function whyNotClose(address caller, uint256 id) view returns (string)",
  "event PositionOpened(uint256 indexed positionId, address indexed trader, bytes32 indexed feedId, (bool isLong, uint16 leverage, uint256 margin, uint256 units, uint256 cost, uint256 price, uint256 fee, int256 fundingIndex) info)",
]);
const ordersAbi = parseAbi([
  "struct OpenRequest { bytes32 feedId; bool isLong; uint256 margin; uint16 leverage; uint256 acceptablePrice; uint256 tp; uint256 sl; }",
  "function requestOpen(OpenRequest r) returns (uint256 id)",
  "function whyNotRequestOpen(address trader, OpenRequest r) view returns (string)",
]);

Enregistrer une clé

viem
const wallet = createWalletClient({ chain: pickle, transport: http(), account: privateKeyToAccount(process.env.WALLET_KEY) });
const key = privateKeyToAccount(process.env.TRADING_KEY); // any key you generated and keep
const account = wallet.account.address;

// The key's consent, under the epochs in force: this ADDS the key.
const digest = await client.readContract({ address: m.bank, abi: bankAbi, functionName: "keyDigest", args: [account, key.address] });
const proof = await key.signMessage({ message: { raw: digest } });

// One wallet transaction: register the key and send it 0.01 ETH for gas.
// (Add pUSD from the wallet as the first argument; it needs no approve.)
const gas = parseEther("0.01");
await wallet.writeContract({
  address: m.bank, abi: bankAbi, functionName: "deposit",
  args: [0n, gas, key.address, proof], value: gas,
});

// From here the key acts for the account.
const keyWallet = createWalletClient({ chain: pickle, transport: http(), account: key });

La clé paie son propre gas. Quand elle passe sous 0,002 ETH, elle peut appeler refuel() sur la banque, une fois par heure, pour remonter à 0,01 ETH depuis l'ETH du compte dans la banque.

Ouvrir une position

viem
const { markets } = await fetch(`${ORIGIN}/api/perp/markets`).then((r) => r.json());
const btc = markets.find((x) => x.symbol === "BTC/USD" && x.enabled && !x.legacy);

const args = {
  feedId: btc.feedId,
  isLong: true,
  margin: parseUnits("10", 6), // $10 of pUSD from the bank balance
  leverage: 5,
  tp: 0n, // oracle units (1e8), 0 for none
  sl: 0n,
};

if (await client.readContract({ address: m.perps, abi: perpsAbi, functionName: "isImmediate", args: [args.feedId] })) {
  // Ask first: revert reasons do not reach a client on this chain.
  const why = await client.readContract({ address: m.perps, abi: perpsAbi, functionName: "whyNotOpen", args: [account, args] });
  if (why) throw new Error(why);
  const hash = await keyWallet.writeContract({ address: m.perps, abi: perpsAbi, functionName: "open", args: [args] });
  await client.waitForTransactionReceipt({ hash });
} else {
  const req = { ...args, acceptablePrice: 0n }; // or a bound in oracle units
  const why = await client.readContract({ address: m.perpOrders, abi: ordersAbi, functionName: "whyNotRequestOpen", args: [account, req] });
  if (why) throw new Error(why);
  await keyWallet.writeContract({ address: m.perpOrders, abi: ordersAbi, functionName: "requestOpen", args: [req] });
  // A keeper fills it at the next price round; follow it on /api/perp/requests?trader=<account>.
}

Fermer et retirer

viem
const { positions } = await fetch(`${ORIGIN}/api/perp/positions?trader=${account}`).then((r) => r.json());
for (const p of positions.filter((x) => !x.legacy)) {
  const why = await client.readContract({ address: m.perps, abi: perpsAbi, functionName: "whyNotClose", args: [key.address, BigInt(p.positionId)] });
  if (!why) await keyWallet.writeContract({ address: m.perps, abi: perpsAbi, functionName: "close", args: [BigInt(p.positionId)] });
}

// Withdraw always pays the account's wallet, even when the key signs.
const bal = await client.readContract({ address: m.bank, abi: bankAbi, functionName: "balanceOf", args: [account, m.pusd] });
await keyWallet.writeContract({ address: m.bank, abi: bankAbi, functionName: "withdraw", args: [m.pusd, bal] });

La même chose avec cast

shell
RPC=https://rpc.picklechain.xyz
ORIGIN=https://sendit.picklechain.xyz
M=$(curl -s $ORIGIN/op-deployment/l2-perps.json)
BANK=$(echo "$M" | jq -r .bank)
PERPS=$(echo "$M" | jq -r .perps)
FEED=$(curl -s $ORIGIN/api/perp/markets | jq -r '.markets[] | select(.symbol=="ETH/USD" and .legacy==false) | .feedId')
ME=$(cast wallet address --private-key $PK)

# Optional: move 100 pUSD from the wallet into the bank (no approve needed).
cast send $BANK "deposit(uint256,uint256,address,bytes)" 100000000 0 $(cast address-zero) 0x \
  --private-key $PK --rpc-url $RPC

# Wallet-signed works too: the wallet is its own account.
cast call $PERPS "whyNotOpen(address,(bytes32,bool,uint256,uint16,uint256,uint256))(string)" \
  $ME "($FEED,true,10000000,5,0,0)" --rpc-url $RPC
cast send $PERPS "open((bytes32,bool,uint256,uint16,uint256,uint256))" \
  "($FEED,true,10000000,5,0,0)" --private-key $PK --rpc-url $RPC

Surveiller

Les positions, les demandes et les exécutions sont sur dex-api, qui indexe les logs des contrats et peut avoir un léger retard sur eux. Pour tout ce qui déplace de l'argent, lisez le moteur : getPosition, positionState, isLiquidatable, et les événements PositionOpened et PositionClosed, dont le trader est toujours le compte.

Si les keepers s'arrêtent

Les exécutions en deux temps, les déclencheurs et les copies sont réalisés par les keepers de SENDIT. S'ils s'arrêtent, une demande expire au bout de 120 secondes et n'importe qui peut l'annuler ; votre marge retourne au solde de la banque.