Перейти к основному содержимому

Постройте торгового бота

Минимальный торговый бот: мерчант публикует объявление о продаже (OFS-2100), затем отдельная идентичность бота его обнаруживает и открывает резервирование против него (OFS-2200). Блокировка эскроу автоматическая по запросу — без отдельного шага подтверждения.

Сначала запустите локальный узел:

cargo run -p openfiat-cli -- --rpc-bind-address 127.0.0.1:7080

Rust

Полный исходник: examples/trading_bot.rs (cargo run --example trading_bot из openfiat-sdks/rust).

let merchant = Keypair::generate();
let bot = Keypair::generate();

// Привязано один раз. Резервирование ниже должно совпасть с ним в точности, а
// запись числа дважды — это как эти двое расходятся.
let advertised_price = Amount::new(12_950, 2);

let create = AdvertisementCreate {
id: AdvertisementId::new("my-ad"),
merchant: peer_id(&merchant),
merchant_public_key: merchant.public_key(),
// Адрес mint, а не тикер — см. ниже.
asset_mint: MintAddress::parse("C4rSGhdxWhSFQuFcAxQti1JvBxriwHJoHtJjfhs5p24Y")?,
direction: Direction::Sell,
fiat_currency: FiatCurrency::parse("KES")?,
// Номинировано в «активе», а не в фиатной валюте выше.
min_trade: Amount::new(1_000, 2),
max_trade: Amount::new(50_000, 2),
initial_liquidity: Amount::new(200_000, 2),
pricing: PricingModel::Fixed { price: advertised_price },
payment_methods: vec!["M-Pesa".to_string()],
timestamp: Timestamp::now(),
};
let ad_id = client.send_advertisement_create(create, &merchant).await?;

// Реальный бот вместо этого вызвал бы client.get_advertisements() с фильтром,
// описывающим его стратегию — резервированию нужен только ID.
let request = ReservationRequest {
id: ReservationId::new("my-reservation"),
advertisement_id: ad_id,
requester: peer_id(&bot),
requester_public_key: bot.public_key(),
amount: Amount::new(5_000, 2),
// Цена, на которую бот соглашается, подписанная в запрос.
agreed_price: advertised_price,
agreed_mid: None,
timestamp: Timestamp::now(),
};
let reservation_id = client.send_reservation_request(request, &bot).await?;

let reservation = client.get_reservation(reservation_id.as_str()).await?.unwrap();
println!("{:?}", reservation.state); // EscrowLocked

Объявление называет mint, а не тикер

asset_mint — это адрес mint Solana в base58. Тикер — это метка, которую выбрал мерчант, и ничто не привязывало её к токену, который эскроу на самом деле сдвинул бы — объявление могло бы сказать «USDT» и рассчитаться в чём-то другом, при этом каждый слой соглашается, что сделка завершилась, потому что каждый сделал ровно то, о чём его просили.

Вы никогда не поставляете символ. Узел разрешает его из mint и возвращает рядом с записью как asset_symbol, который равен null для mint, для которого у него нет имени. Показывайте адрес в этом случае; адрес без прозвища бесполезен и правдив, а догадка полезна и ложна.

Резервирование фиксирует цену, на которую согласилось

agreed_price подписан в запрос, и узел проверяет, что он следует из собственных условий объявления, — отклоняя с PRICE_DISAGREEMENT иначе. Это то, что мешает мерчанту переоценивать между котировкой, которую прочитал бот, и эскроу, который он блокирует.

Это объявление Fixed, поэтому согласованная цена — просто то, что оно объявляет, и записывать mid нечего. Против Floating бот читает оба из quote в getAdvertisement и передаёт также agreed_mid — это позволяет узлу заново вывести то же число из того же наблюдения оракула, а не из своего, которое может отличаться.

getReservation — открытое, неаутентифицированное чтение, поэтому то, что возвращается, — это отредактированный вид: состояние, сумма и объявление там, запрашивающего нет. Бот, которому нужны его собственные резервирования полностью — включая поля сторон — читает их вместо этого с доказательством кошелька.

TypeScript

Полный исходник: examples/trading_bot.ts (pnpm tsx examples/trading_bot.ts из openfiat-sdks/typescript).

const merchant = await generateKeypair();
const bot = await generateKeypair();

// Привязано один раз: резервирование ниже должно совпасть с ним в точности.
const advertisedPrice = { base_units: 12_950, decimals: 2 };

const create: AdvertisementCreate = {
id: "my-ad",
merchant: toBytes(peerIdFromPublicKey(merchant.publicKey)),
merchant_public_key: toBytes(merchant.publicKey),
asset_mint: "C4rSGhdxWhSFQuFcAxQti1JvBxriwHJoHtJjfhs5p24Y",
direction: "Sell",
fiat_currency: "KES",
min_trade: { base_units: 1_000, decimals: 2 },
max_trade: { base_units: 50_000, decimals: 2 },
initial_liquidity: { base_units: 200_000, decimals: 2 },
pricing: { Fixed: { price: advertisedPrice } },
payment_methods: ["M-Pesa"],
timestamp: Date.now(),
};
const adId = await advertisements.sendAdvertisementCreate(client, create, merchant);

// Реальный бот вместо этого вызвал бы advertisements.getAdvertisements(client, {…})
// с фильтром, описывающим его стратегию — резервированию нужен только ID.
const request: ReservationRequest = {
id: "my-reservation",
advertisement_id: adId,
requester: toBytes(peerIdFromPublicKey(bot.publicKey)),
requester_public_key: toBytes(bot.publicKey),
amount: { base_units: 5_000, decimals: 2 },
agreed_price: advertisedPrice,
agreed_mid: null,
timestamp: Date.now(),
};
const reservationId = await reservations.sendReservationRequest(client, request, bot);

const reservation = await reservations.getReservation(client, reservationId);
console.log(reservation?.state); // "EscrowLocked"

Чтение ордербука

getAdvertisements принимает фильтр и курсор и отвечает одной страницей: { advertisements, next_cursor }. Раньше он отвечал голым массивом каждого объявления сети, из вызова, который не принимал параметров, — ответ, растущий без границ по книге, в которой никто не мог искать.

Сужайте в запросе. Фильтрация страницы после её прибытия не просто тратит передачу: граница страницы была решена по строкам, которые вы затем выбросили, поэтому курсор больше не значит то, что вы думаете.

let cursor: string | null = null;
do {
const page = await advertisements.getAdvertisements(client, {
filter: { fiat_currency: "KES", direction: "Sell" },
page: { after: cursor, limit: 25 },
});
for (const ad of page.advertisements) {
// `quote` — размеченное объединение, и три случая — три разных обещания.
// `Fixed` двигается только когда мерчант подписывает новое; `Floating`
// действителен до `mid_expires_at` и может сдвинуться раньше; `Unpriceable`
// означает, что объявление существует и сейчас без цены. Чтение `price` из
// него без сужения отрисовало бы неоцениваемое объявление как бесплатное.
switch (ad.quote.kind) {
case "Fixed":
consider(ad, ad.quote.price);
break;
case "Floating":
if (ad.quote.mid_expires_at > Date.now()) consider(ad, ad.quote.price);
break;
case "Unpriceable":
skip(ad, ad.quote.reason); // NoOracleData | StaleOracleData | PriceOutOfRange
break;
}
}
cursor = page.next_cursor;
} while (cursor !== null);

Передавайте next_cursor обратно дословно. Он по случайности является id объявления, и всё же его не следует реконструировать из последней полученной вами строки: делать это значит заново реализовывать упорядочивание узла, а читателю, чьё упорядочивание расходится, вручают одни строки дважды, а другие никогда, без чего-либо, что бы на это указало. Останавливайтесь, когда курсор равен null, а не когда страница пуста — полная страница не доказывает, что существует другая, поэтому узел может вернуть курсор, за которым ничего нет.