面向开发者 / SENDIT

构建机器人

应用做的每一件事都是你也能发起的合约调用。读取清单,为银行注资,然后从钱包或已登记的密钥发送交易。

钱包还是密钥

每个场所都通过 bank.accountOf 解析调用者,而钱包解析为它自己,所以机器人可以直接从一个 pUSD 已存入银行的钱包进行交易。当机器人不应持有钱包时,就登记一把密钥:密钥可以交易和转出银行余额,并提取到钱包,除此之外什么都不能做。

准备

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)",
]);

登记一把密钥

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 });

密钥自己支付 gas。当它低于 0.002 ETH 时,它可以在银行上调用 refuel(),每小时一次,从账户在银行中的 ETH 补充到 0.01 ETH。

开仓

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>.
}

平仓与提取

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] });

用 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

监控

仓位、请求和成交记录在 dex-api 上,它索引合约的日志,可能会稍有滞后。凡是涉及资金的,请读取引擎:getPosition、positionState、isLiquidatable,以及 PositionOpened 和 PositionClosed 事件,其中的 trader 总是账户。

如果守护者停止运行

两步成交、触发单和跟单镜像都由 SENDIT 的守护者执行。如果它们停止运行,请求会在 120 秒后过期,任何人都可以取消它;你的保证金回到银行余额。