diff --git a/TASK-token-migration.md b/TASK-token-migration.md new file mode 100644 index 0000000..83bb7cb --- /dev/null +++ b/TASK-token-migration.md @@ -0,0 +1,79 @@ +# TASK — перевод смарт-контракта smart-updown на фантики (SPL-токен) + +Репозиторий: `smart-updown-token` (копия `Smart-game`, ветка `main`). Оригинал НЕ трогаем. + +> Работаем ТОЛЬКО `program/programs/smart-updown/src/**`. Остальное (relayer, deploy) не менять +> в этом проходе. + +## Цель + +Контракт сейчас принимает/выплачивает **нативный SOL (lamports)** через `system_program` и +`add/sub_lamports`. Перевести на **наши фантики = SPL-token** (обычный SPL Token, НЕ Token-2022 — +для MVP берём простой стандарт; swap на pump.fun конвертит SOL↔фантики безотносительно стандарта). + +**Логика игры не меняется:** ставка вверх/вниз по цене SOL, `entry_price`/`exit_price` — обычные +u64 и сравниваются как раньше. Меняется ТОЛЬКО норма расчётов (валюта): с lamports на токен-единицы. + +## Что изменится + +### 1. `state.rs` +- Поля `Global` остаются (counter, min/max_amount, multiplier_bps, expiry_seconds, paused, admin, relayer). + `min_amount`/`max_amount` теперь = **токен-единицы**, не lamports. (decimals=6; логика количеств не меняется, просто ценовая шкала). +- `Bet` — без изменений (amount = токен-единицы). + +### 2. `create_bet.rs` +- Добавить аккаунты (через `anchor_spl::token::Token`): + - `bettor_token: Account<'info, TokenAccount>` (ATA игрока, `#[account(mut)]`) — откуда списываем фантики. + - `vault_token: Account<'info, TokenAccount>` (ATA на global, `#[account(seeds=[GLOBAL_SEED], bump)]`, `mut`) — куда кладём. + - `token_program: Program<'info, Token>`. +- Заменить блок `system_program::transfer(...)` на `anchor_spl::token::transfer(...)`: + из `bettor_token` в `vault_token`, `amount`. +- `bettor` остаётся `Signer`, но теперь просто auth (токен-подпись). + +### 3. `close_bet.rs` +- Добавить: + - `vault_token: Account<'info, TokenAccount>` (`mut`, seeds GLOBAL_SEED) — источник фантиков. + - `bettor_token: Account<'info, TokenAccount>` (`mut`) — куда платим. + - `token_program: Program<'info, Token>`. +- Заменить ваулт-логику: вместо `add/sub_lamports` у global — `token::transfer` из `vault_token` + в `bettor_token` на `payout`. +- `global` больше НЕ касса SOL — убрать `SystemAccount`-требование; `bettor` в `close_bet` можно убрать + из аккаунтов (адрес в `bet.bettor` уже есть), но для обратной совместимости можно оставить как `SystemAccount` (не мутируя). +- Проверка `global.get_lamports() >= payout` заменяется на проверку баланса `vault_token.amount >= payout` + (или просто положиться на token::transfer failure — но лучше явно проверить `InsufficientVault`). + +### 4. `initialize.rs` / `update.rs` +- Убедиться, что `min/max_amount` интерпретируются как токен-единицы. Если в `initialize` передаются + как lamports — просто поменять смысл (или оставить, числа вводим в токен-единицах). Логику не трогаем. + +### 5. Cargo.toml +- Добавить зависимость: `anchor-spl = "0.31.1"` (даёт `anchor_spl::token`). + +### 6. IDL (idl/*.json) +- Обновить после сборки (`anchor build` регенерит). Новые аккаунты в create_bet/close_bet. + +## Требования (гейты) + +1. Каждый шаг компилируется: `anchor build` (или `cargo build-sbf`) — БЕЗ ошибок. +2. В конце: `anchor build` зелёный + обновлённый IDL. +3. Семантика: create_bet берёт фантики с bettor на ваулт; close_bet платит фантики с ваулта на bettor. +4. Тесты (`program/tests/`) — обновить под токен (создать mint, ATA, задеплоить), прогнать `anchor test`. + +## Порядок работы (шаг за шагом, компиляз после каждого) + +- **Шаг 1** — Cargo.toml: + анchor-spl. +- **Шаг 2** — state.rs: только комментарий (min/max = токен-единицы), без изменения полей. +- **Шаг 3** — create_bet.rs: добавить токен-аккаунты + `token::transfer`. +- **Шаг 4** — close_bet.rs: добавить токен-аккаунты + `token::transfer` из ваулта на bettor. +- **Шаг 5** — IDL реген + тесты. +- **Шаг 6** — финальный `anchor test` зелёный. + +## ЧЕГО НЕ ДЕЛАТЬ +- Не трогать логику «вверх/вниз», entry/exit_price, статусы. +- Не менять relayer/скрипты деплоя. +- Не коммитить (всё остаётся в рабочем дереве до приёмки человеком). + +## Проверка +- `anchor build` — 0 ошибок. +- `anchor test` (или `cargo test` в program) — зелёный. +- Все новые аккаунты корректно работают с токен-кейсами (mint, ATA, ваулт). \ No newline at end of file diff --git a/idl/smart_updown.json b/idl/smart_updown.json index 74b4b4f..6401805 100644 --- a/idl/smart_updown.json +++ b/idl/smart_updown.json @@ -10,7 +10,14 @@ { "name": "close_bet", "discriminator": [ - 2 + 185, + 206, + 13, + 184, + 176, + 108, + 140, + 107 ], "accounts": [ { @@ -57,15 +64,54 @@ } }, { - "name": "bettor", - "docs": [ - "CHECK: recipient must be the bet's bettor and is validated in the handler." - ], - "writable": true + "name": "bettor" }, { "name": "clock", "address": "SysvarC1ock11111111111111111111111111111111" + }, + { + "name": "vault_token", + "docs": [ + "Program-owned token vault: PDA([GLOBAL_SEED, VAULT_SEED]); payouts are paid out from here." + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108 + ] + }, + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116 + ] + } + ] + } + }, + { + "name": "bettor_token", + "docs": [ + "Bettor's SPL-token account; the payout is sent here." + ], + "writable": true + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" } ], "args": [ @@ -82,7 +128,14 @@ { "name": "create_bet", "discriminator": [ - 1 + 197, + 42, + 153, + 2, + 59, + 63, + 143, + 246 ], "accounts": [ { @@ -136,6 +189,49 @@ { "name": "system_program", "address": "11111111111111111111111111111111" + }, + { + "name": "bettor_token", + "docs": [ + "Bettor's SPL-token account (our \"fanti\" mint); bet amount is pulled from here." + ], + "writable": true + }, + { + "name": "vault_token", + "docs": [ + "Program-owned token vault: PDA([GLOBAL_SEED, VAULT_SEED]); bet amounts are escrowed here." + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108 + ] + }, + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116 + ] + } + ] + } + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" } ], "args": [ @@ -160,7 +256,14 @@ { "name": "initialize", "discriminator": [ - 0 + 175, + 175, + 109, + 31, + 13, + 152, + 155, + 237 ], "accounts": [ { @@ -214,7 +317,14 @@ { "name": "update", "discriminator": [ - 3 + 219, + 200, + 88, + 176, + 158, + 63, + 253, + 127 ], "accounts": [ { @@ -380,10 +490,9 @@ "types": [ { "name": "Bet", - "serialization": "bytemuck", - "repr": { - "kind": "c" - }, + "docs": [ + "Open bet. `amount` is in token base units (decimals = 6), not lamports." + ], "type": { "kind": "struct", "fields": [ @@ -488,10 +597,13 @@ }, { "name": "Global", - "serialization": "bytemuck", - "repr": { - "kind": "c" - }, + "docs": [ + "Global game state.", + "", + "All money-related fields are denominated in **SPL-token base units**", + "(our \"fanti\" token, decimals = 6), not in lamports:", + "`min_amount`/`max_amount` bound a bet amount in token units." + ], "type": { "kind": "struct", "fields": [ @@ -541,6 +653,15 @@ "name": "GLOBAL_SEED", "type": "bytes", "value": "[103, 108, 111, 98, 97, 108]" + }, + { + "name": "VAULT_SEED", + "docs": [ + "Vault PDA seed: the token vault lives at PDA([GLOBAL_SEED, VAULT_SEED]).", + "It cannot reuse GLOBAL_SEED alone — that PDA address is the `global` state account itself." + ], + "type": "bytes", + "value": "[118, 97, 117, 108, 116]" } ] -} \ No newline at end of file +} diff --git a/program/Cargo.lock b/program/Cargo.lock index 581cebb..7c2f9a5 100644 --- a/program/Cargo.lock +++ b/program/Cargo.lock @@ -294,6 +294,7 @@ dependencies = [ "anchor-derive-accounts", "anchor-derive-serde", "anchor-derive-space", + "anchor-lang-idl", "base64 0.21.7", "bincode", "borsh 0.10.4", @@ -311,6 +312,7 @@ dependencies = [ "anchor-lang-idl-spec", "anyhow", "heck 0.3.3", + "regex", "serde", "serde_json", "sha2 0.10.9", @@ -326,6 +328,21 @@ dependencies = [ "serde", ] +[[package]] +name = "anchor-spl" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c08cb5d762c0694f74bd02c9a5b04ea53cefc496e2c27b3234acffca5cd076b" +dependencies = [ + "anchor-lang", + "spl-associated-token-account", + "spl-pod", + "spl-token", + "spl-token-2022", + "spl-token-group-interface", + "spl-token-metadata-interface", +] + [[package]] name = "anchor-syn" version = "0.31.1" @@ -334,6 +351,7 @@ checksum = "5dc7a6d90cc643df0ed2744862cdf180587d1e5d28936538c18fc8908489ed67" dependencies = [ "anyhow", "bs58", + "cargo_toml", "heck 0.3.3", "proc-macro2", "quote", @@ -892,6 +910,16 @@ dependencies = [ "libc", ] +[[package]] +name = "cargo_toml" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a98356df42a2eb1bd8f1793ae4ee4de48e384dd974ce5eac8eee802edb7492be" +dependencies = [ + "serde", + "toml 0.8.23", +] + [[package]] name = "cc" version = "1.4.4" @@ -3171,7 +3199,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" dependencies = [ - "toml", + "toml 0.5.11", ] [[package]] @@ -3180,7 +3208,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -3854,6 +3882,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4035,6 +4072,8 @@ name = "smart-updown" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-spl", + "solana-account", "solana-client", "solana-clock", "solana-keypair", @@ -4042,6 +4081,8 @@ dependencies = [ "solana-pubkey", "solana-signer", "solana-transaction", + "spl-associated-token-account", + "spl-token", "tokio", ] @@ -6046,6 +6087,12 @@ dependencies = [ "solana-sdk-ids", ] +[[package]] +name = "solana-security-txt" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94a02d486b28f219a4f8f5d7dd93cbfbb93c9f466cb7871c22e50cd5ae9a7a2" + [[package]] name = "solana-seed-derivable" version = "2.2.1" @@ -6977,6 +7024,81 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spl-associated-token-account" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76fee7d65013667032d499adc3c895e286197a35a0d3a4643c80e7fd3e9969e3" +dependencies = [ + "borsh 1.8.1", + "num-derive", + "num-traits", + "solana-program", + "spl-associated-token-account-client", + "spl-token", + "spl-token-2022", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-associated-token-account-client" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f8349dbcbe575f354f9a533a21f272f3eb3808a49e2fdc1c34393b88ba76cb" +dependencies = [ + "solana-instruction", + "solana-pubkey", +] + +[[package]] +name = "spl-discriminator" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7398da23554a31660f17718164e31d31900956054f54f52d5ec1be51cb4f4b3" +dependencies = [ + "bytemuck", + "solana-program-error", + "solana-sha256-hasher", + "spl-discriminator-derive", +] + +[[package]] +name = "spl-discriminator-derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9e8418ea6269dcfb01c712f0444d2c75542c04448b480e87de59d2865edc750" +dependencies = [ + "quote", + "spl-discriminator-syn", + "syn 2.0.119", +] + +[[package]] +name = "spl-discriminator-syn" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1dbc82ab91422345b6df40a79e2b78c7bce1ebb366da323572dd60b7076b67" +dependencies = [ + "proc-macro2", + "quote", + "sha2 0.10.9", + "syn 2.0.119", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-elgamal-registry" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce0f668975d2b0536e8a8fd60e56a05c467f06021dae037f1d0cfed0de2e231d" +dependencies = [ + "bytemuck", + "solana-program", + "solana-zk-sdk", + "spl-pod", + "spl-token-confidential-transfer-proof-extraction", +] + [[package]] name = "spl-generic-token" version = "1.0.1" @@ -6987,6 +7109,250 @@ dependencies = [ "solana-pubkey", ] +[[package]] +name = "spl-memo" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f09647c0974e33366efeb83b8e2daebb329f0420149e74d3a4bd2c08cf9f7cb" +dependencies = [ + "solana-account-info", + "solana-instruction", + "solana-msg", + "solana-program-entrypoint", + "solana-program-error", + "solana-pubkey", +] + +[[package]] +name = "spl-pod" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d994afaf86b779104b4a95ba9ca75b8ced3fdb17ee934e38cb69e72afbe17799" +dependencies = [ + "borsh 1.8.1", + "bytemuck", + "bytemuck_derive", + "num-derive", + "num-traits", + "solana-decode-error", + "solana-msg", + "solana-program-error", + "solana-program-option", + "solana-pubkey", + "solana-zk-sdk", + "thiserror 2.0.20", +] + +[[package]] +name = "spl-program-error" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d39b5186f42b2b50168029d81e58e800b690877ef0b30580d107659250da1d1" +dependencies = [ + "num-derive", + "num-traits", + "solana-program", + "spl-program-error-derive", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-program-error-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d375dd76c517836353e093c2dbb490938ff72821ab568b545fd30ab3256b3e" +dependencies = [ + "proc-macro2", + "quote", + "sha2 0.10.9", + "syn 2.0.119", +] + +[[package]] +name = "spl-tlv-account-resolution" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd99ff1e9ed2ab86e3fd582850d47a739fec1be9f4661cba1782d3a0f26805f3" +dependencies = [ + "bytemuck", + "num-derive", + "num-traits", + "solana-account-info", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-program-error", + "solana-pubkey", + "spl-discriminator", + "spl-pod", + "spl-program-error", + "spl-type-length-value", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-token" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed320a6c934128d4f7e54fe00e16b8aeaecf215799d060ae14f93378da6dc834" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-program", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-token-2022" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b27f7405010ef816587c944536b0eafbcc35206ab6ba0f2ca79f1d28e488f4f" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-program", + "solana-security-txt", + "solana-zk-sdk", + "spl-elgamal-registry", + "spl-memo", + "spl-pod", + "spl-token", + "spl-token-confidential-transfer-ciphertext-arithmetic", + "spl-token-confidential-transfer-proof-extraction", + "spl-token-confidential-transfer-proof-generation", + "spl-token-group-interface", + "spl-token-metadata-interface", + "spl-transfer-hook-interface", + "spl-type-length-value", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-token-confidential-transfer-ciphertext-arithmetic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170378693c5516090f6d37ae9bad2b9b6125069be68d9acd4865bbe9fc8499fd" +dependencies = [ + "base64 0.22.1", + "bytemuck", + "solana-curve25519", + "solana-zk-sdk", +] + +[[package]] +name = "spl-token-confidential-transfer-proof-extraction" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff2d6a445a147c9d6dd77b8301b1e116c8299601794b558eafa409b342faf96" +dependencies = [ + "bytemuck", + "solana-curve25519", + "solana-program", + "solana-zk-sdk", + "spl-pod", + "thiserror 2.0.20", +] + +[[package]] +name = "spl-token-confidential-transfer-proof-generation" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8627184782eec1894de8ea26129c61303f1f0adeed65c20e0b10bc584f09356d" +dependencies = [ + "curve25519-dalek 4.1.3", + "solana-zk-sdk", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-token-group-interface" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d595667ed72dbfed8c251708f406d7c2814a3fa6879893b323d56a10bedfc799" +dependencies = [ + "bytemuck", + "num-derive", + "num-traits", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-program-error", + "solana-pubkey", + "spl-discriminator", + "spl-pod", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-token-metadata-interface" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfb9c89dbc877abd735f05547dcf9e6e12c00c11d6d74d8817506cab4c99fdbb" +dependencies = [ + "borsh 1.8.1", + "num-derive", + "num-traits", + "solana-borsh", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-program-error", + "solana-pubkey", + "spl-discriminator", + "spl-pod", + "spl-type-length-value", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-transfer-hook-interface" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa7503d52107c33c88e845e1351565050362c2314036ddf19a36cd25137c043" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "solana-account-info", + "solana-cpi", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-program-error", + "solana-pubkey", + "spl-discriminator", + "spl-pod", + "spl-program-error", + "spl-tlv-account-resolution", + "spl-type-length-value", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-type-length-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba70ef09b13af616a4c987797870122863cba03acc4284f226a4473b043923f9" +dependencies = [ + "bytemuck", + "num-derive", + "num-traits", + "solana-account-info", + "solana-decode-error", + "solana-msg", + "solana-program-error", + "spl-discriminator", + "spl-pod", + "thiserror 1.0.69", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7157,7 +7523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -7409,6 +7775,27 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -7418,6 +7805,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.13+spec-1.1.0" @@ -7425,9 +7826,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -7436,9 +7837,15 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -8021,6 +8428,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" diff --git a/program/programs/smart-updown/Cargo.toml b/program/programs/smart-updown/Cargo.toml index 6c34624..5b3a3a4 100644 --- a/program/programs/smart-updown/Cargo.toml +++ b/program/programs/smart-updown/Cargo.toml @@ -14,10 +14,11 @@ default = [] cpi = ["no-entrypoint"] no-entrypoint = [] no-log-ix-name = [] -idl-build = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] [dependencies] anchor-lang = "0.31.1" +anchor-spl = "0.31.1" [dev-dependencies] anchor-lang = "0.31.1" @@ -27,7 +28,10 @@ solana-pubkey = "2.1" solana-signer = "2.1" solana-keypair = "2.1" solana-clock = "2.1" +solana-account = "2.1" solana-transaction = "2.1" +spl-token = "7.0.0" +spl-associated-token-account = "6.0.0" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [lints.rust] diff --git a/program/programs/smart-updown/src/constants.rs b/program/programs/smart-updown/src/constants.rs index 7e493df..9b0343a 100644 --- a/program/programs/smart-updown/src/constants.rs +++ b/program/programs/smart-updown/src/constants.rs @@ -6,6 +6,11 @@ pub const GLOBAL_SEED: &[u8] = b"global"; #[constant] pub const BET_SEED: &[u8] = b"bet"; +/// Vault PDA seed: the token vault lives at PDA([GLOBAL_SEED, VAULT_SEED]). +/// It cannot reuse GLOBAL_SEED alone — that PDA address is the `global` state account itself. +#[constant] +pub const VAULT_SEED: &[u8] = b"vault"; + pub const SIDE_UP: u64 = 0; pub const SIDE_DOWN: u64 = 1; diff --git a/program/programs/smart-updown/src/instructions/close_bet.rs b/program/programs/smart-updown/src/instructions/close_bet.rs index dd035dd..2ce4d3d 100644 --- a/program/programs/smart-updown/src/instructions/close_bet.rs +++ b/program/programs/smart-updown/src/instructions/close_bet.rs @@ -1,9 +1,10 @@ use anchor_lang::prelude::*; +use anchor_spl::token::{Token, TokenAccount}; use crate::{ constants::{ BPS_DIVISOR, BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_HOUSE_WON, - STATUS_PAYOUT_DONE, STATUS_OPEN, + STATUS_PAYOUT_DONE, STATUS_OPEN, VAULT_SEED, }, error::SmartUpdownError, events::BetClosed, @@ -27,9 +28,19 @@ pub struct CloseBet<'info> { )] pub global: Account<'info, Global>, /// CHECK: recipient must be the bet's bettor and is validated in the handler. - #[account(mut)] pub bettor: SystemAccount<'info>, pub clock: Sysvar<'info, Clock>, + /// Program-owned token vault: PDA([GLOBAL_SEED, VAULT_SEED]); payouts are paid out from here. + #[account( + mut, + seeds = [GLOBAL_SEED, VAULT_SEED], + bump + )] + pub vault_token: Account<'info, TokenAccount>, + /// Bettor's SPL-token account; the payout is sent here. + #[account(mut)] + pub bettor_token: Account<'info, TokenAccount>, + pub token_program: Program<'info, Token>, } pub fn handler(ctx: &mut Context, id: u64, exit_price: u64) -> Result<()> { @@ -69,17 +80,36 @@ let payout; .checked_div(BPS_DIVISOR as u128) .unwrap() as u64; require!( - ctx.accounts.global.get_lamports() >= payout_amount, + ctx.accounts.vault_token.amount >= payout_amount, SmartUpdownError::InsufficientVault ); payout = payout_amount; - ctx.accounts.global.sub_lamports(payout)?; - ctx.accounts.bettor.add_lamports(payout)?; + { + // Pay the bettor: transfer the payout from the vault to the bettor's token account. + // The vault is owned by the `global` PDA, so the CPI is signed with its seeds. + let cpi_accounts = anchor_spl::token::Transfer { + from: ctx.accounts.vault_token.to_account_info(), + to: ctx.accounts.bettor_token.to_account_info(), + authority: ctx.accounts.vault_token.to_account_info(), + }; + let signer_seeds: &[&[u8]] = &[ + GLOBAL_SEED, + VAULT_SEED, + &[ctx.bumps.vault_token], + ]; + let vault_signer_seeds = [signer_seeds]; + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + cpi_accounts, + &vault_signer_seeds, + ); + anchor_spl::token::transfer(cpi_ctx, payout)?; + } ctx.accounts.bet.status = STATUS_PAYOUT_DONE; } else { - payout = 0; - ctx.accounts.bet.status = STATUS_HOUSE_WON; - } + payout = 0; + ctx.accounts.bet.status = STATUS_HOUSE_WON; + } ctx.accounts.bet.exit_price = exit_price; diff --git a/program/programs/smart-updown/src/instructions/create_bet.rs b/program/programs/smart-updown/src/instructions/create_bet.rs index ecb935e..58eea2b 100644 --- a/program/programs/smart-updown/src/instructions/create_bet.rs +++ b/program/programs/smart-updown/src/instructions/create_bet.rs @@ -1,7 +1,8 @@ use anchor_lang::prelude::*; +use anchor_spl::token::{Token, TokenAccount}; use crate::{ - constants::{BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_OPEN}, + constants::{BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_OPEN, VAULT_SEED}, error::SmartUpdownError, events::BetCreated, state::{Bet, Global}, @@ -28,6 +29,17 @@ pub struct CreateBet<'info> { pub bet: Account<'info, Bet>, pub clock: Sysvar<'info, Clock>, pub system_program: Program<'info, System>, + /// Bettor's SPL-token account (our "fanti" mint); bet amount is pulled from here. + #[account(mut)] + pub bettor_token: Account<'info, TokenAccount>, + /// Program-owned token vault: PDA([GLOBAL_SEED, VAULT_SEED]); bet amounts are escrowed here. + #[account( + mut, + seeds = [GLOBAL_SEED, VAULT_SEED], + bump + )] + pub vault_token: Account<'info, TokenAccount>, + pub token_program: Program<'info, Token>, } pub fn handler( @@ -72,15 +84,18 @@ pub fn handler( ctx.accounts.global.counter = ctx.accounts.global.counter.checked_add(1).unwrap(); { - let cpi_accounts = anchor_lang::system_program::Transfer { - from: ctx.accounts.bettor.to_account_info(), - to: ctx.accounts.global.to_account_info(), + // Escrow: move the bet amount (token units) from the bettor's account to the vault. + // The SPL token program enforces that `bettor_token` is owned by `bettor` (authority). + let cpi_accounts = anchor_spl::token::Transfer { + from: ctx.accounts.bettor_token.to_account_info(), + to: ctx.accounts.vault_token.to_account_info(), + authority: ctx.accounts.bettor.to_account_info(), }; let cpi_ctx = CpiContext::new( - ctx.accounts.system_program.to_account_info(), + ctx.accounts.token_program.to_account_info(), cpi_accounts, ); - anchor_lang::system_program::transfer(cpi_ctx, amount)?; + anchor_spl::token::transfer(cpi_ctx, amount)?; } emit!(BetCreated { diff --git a/program/programs/smart-updown/src/state.rs b/program/programs/smart-updown/src/state.rs index c242b9e..1e57b2c 100644 --- a/program/programs/smart-updown/src/state.rs +++ b/program/programs/smart-updown/src/state.rs @@ -1,5 +1,10 @@ use anchor_lang::prelude::*; +/// Global game state. +/// +/// All money-related fields are denominated in **SPL-token base units** +/// (our "fanti" token, decimals = 6), not in lamports: +/// `min_amount`/`max_amount` bound a bet amount in token units. #[account] #[derive(InitSpace)] pub struct Global { @@ -13,6 +18,7 @@ pub struct Global { pub relayer: Pubkey, } +/// Open bet. `amount` is in token base units (decimals = 6), not lamports. #[account] #[derive(InitSpace)] pub struct Bet { diff --git a/program/programs/smart-updown/tests/test_smart_updown.rs b/program/programs/smart-updown/tests/test_smart_updown.rs index 294a3ec..c39ba74 100644 --- a/program/programs/smart-updown/tests/test_smart_updown.rs +++ b/program/programs/smart-updown/tests/test_smart_updown.rs @@ -2,26 +2,43 @@ use { anchor_lang::{ solana_program::{ instruction::Instruction, + program_option::COption, + program_pack::Pack, pubkey::Pubkey, + rent::Rent, system_instruction, }, AccountDeserialize, InstructionData, ToAccountMetas, }, - smart_updown::state::{Bet, Global}, + smart_updown::{constants::VAULT_SEED, state::{Bet, Global}}, + solana_account::{Account as SolanaAccount}, solana_clock::Clock, solana_keypair::Keypair, solana_program_test::{BanksClient, ProgramTest, ProgramTestContext}, solana_signer::Signer, solana_transaction::Transaction, + spl_associated_token_account, + spl_token, + spl_token::state::{Account as TokenData, AccountState, Mint}, }; const CLOCK_ADDR: &str = "SysvarC1ock11111111111111111111111111111111"; const SYSTEM_ADDR: &str = "11111111111111111111111111111111"; -const MIN_AMOUNT: u64 = 1_000_000_000; // 1 SOL +const MIN_AMOUNT: u64 = 1_000_000_000; // minimum bet, token units const MULTIPLIER_BPS: u64 = 13_000; // 1.3x const EXPIRY_SECONDS: i64 = 15; const START_TIME: i64 = 1_700_000_000; +// SPL token (фантики) environment +const TOKEN_DECIMALS: u8 = 6; +const MINT_SIZE: u64 = 82; // classic SPL token mint account size +const TOKEN_ACCOUNT_SIZE: u64 = 165; // classic SPL token account size +const VAULT_TOKEN_SUPPLY: u64 = 100 * MIN_AMOUNT; // vault seed, covers many payouts +const BETTOR_TOKEN_SUPPLY: u64 = 10 * MIN_AMOUNT; // per-bettor token supply +/// Fixed фантики mint: mint + vault token accounts are pre-loaded into +/// genesis because the vault is a PDA and cannot sign its own create_account. +const MINT: Pubkey = Pubkey::new_from_array([3u8; 32]); + fn pid() -> Pubkey { smart_updown::id() } @@ -30,16 +47,73 @@ fn global_pda() -> Pubkey { Pubkey::find_program_address(&[b"global"], &pid()).0 } +fn vault_pda() -> Pubkey { + Pubkey::find_program_address(&[b"global", VAULT_SEED], &pid()).0 +} + fn bet_pda(id: u64) -> Pubkey { Pubkey::find_program_address(&[b"bet", &id.to_le_bytes()], &pid()).0 } +/// Boot the program-test context with the фантики mint and the program's +/// token vault (PDA([global, vault])) pre-loaded into genesis. The vault is a +/// PDA owned by itself, so it can never sign a top-level create_account; the +/// account data below matches what the token program would have created. async fn setup() -> (ProgramTestContext, Keypair) { + let admin = Keypair::new(); + let mint_data = { + let m = Mint { + mint_authority: COption::Some(admin.pubkey()), + supply: 0, + decimals: TOKEN_DECIMALS, + is_initialized: true, + freeze_authority: COption::None, + }; + let mut buf = [0u8; MINT_SIZE as usize]; + Pack::pack(m, &mut buf).expect("pack mint"); + buf + }; + let vault_data = { + let a = TokenData { + mint: MINT, + owner: vault_pda(), + amount: VAULT_TOKEN_SUPPLY, + delegate: COption::None, + state: AccountState::Initialized, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::None, + }; + let mut buf = [0u8; TOKEN_ACCOUNT_SIZE as usize]; + Pack::pack(a, &mut buf).expect("pack vault token account"); + buf + }; + let mut pt = ProgramTest::default(); pt.add_program("smart_updown", pid(), None); + let rent = Rent::default(); + pt.add_account( + MINT, + SolanaAccount { + lamports: rent.minimum_balance(MINT_SIZE as usize), + data: mint_data.to_vec(), + owner: spl_token::ID, + executable: false, + rent_epoch: 0, + }, + ); + pt.add_account( + vault_pda(), + SolanaAccount { + lamports: rent.minimum_balance(TOKEN_ACCOUNT_SIZE as usize), + data: vault_data.to_vec(), + owner: spl_token::ID, + executable: false, + rent_epoch: 0, + }, + ); // Fund the deploy keypair separately in the test; the validator in CI funds it. let context = pt.start_with_context().await; - let admin = Keypair::new(); context.banks_client.process_transaction(Transaction::new_signed_with_payer( &[system_instruction::transfer( &context.payer.pubkey(), @@ -93,7 +167,14 @@ fn make_initialize_ix(admin: Pubkey) -> Instruction { ) } -fn make_create_ix(id: u64, side: u64, amount: u64, entry_price: u64, bettor: Pubkey) -> Instruction { +fn make_create_ix( + id: u64, + side: u64, + amount: u64, + entry_price: u64, + bettor: Pubkey, + bettor_ata: &Pubkey, +) -> Instruction { Instruction::new_with_bytes( pid(), &smart_updown::instruction::CreateBet { @@ -109,12 +190,21 @@ fn make_create_ix(id: u64, side: u64, amount: u64, entry_price: u64, bettor: Pub bet: bet_pda(id), clock: CLOCK_ADDR.parse().unwrap(), system_program: SYSTEM_ADDR.parse().unwrap(), + bettor_token: *bettor_ata, + vault_token: vault_pda(), + token_program: spl_token::ID, } .to_account_metas(None), ) } -fn make_close_ix(id: u64, exit_price: u64, relayer: Pubkey, bettor: Pubkey) -> Instruction { +fn make_close_ix( + id: u64, + exit_price: u64, + relayer: Pubkey, + bettor: Pubkey, + bettor_ata: &Pubkey, +) -> Instruction { Instruction::new_with_bytes( pid(), &smart_updown::instruction::CloseBet { id, exit_price }.data(), @@ -124,6 +214,9 @@ fn make_close_ix(id: u64, exit_price: u64, relayer: Pubkey, bettor: Pubkey) -> I global: global_pda(), bettor, clock: CLOCK_ADDR.parse().unwrap(), + vault_token: vault_pda(), + bettor_token: *bettor_ata, + token_program: spl_token::ID, } .to_account_metas(None), ) @@ -167,22 +260,90 @@ async fn airdrop_to(ctx: &mut ProgramTestContext, to: &Pubkey, lamports: u64) { ctx.banks_client.process_transaction(tx).await.unwrap(); } -async fn fund_vault(ctx: &mut ProgramTestContext, lamports: u64) { - airdrop_to(ctx, &global_pda(), lamports).await; +/// Create the token mint, the program's token vault (PDA([global, vault]), +/// owned by the global PDA), and seed the vault with tokens. +// The фантики env (mint + seeded vault) is pre-loaded into genesis by setup(); +// a PDA vault can never sign its own top-level create_account, so this only +// returns the fixed mint the genesis accounts were built around. +async fn create_token_env(_ctx: &mut ProgramTestContext, _admin: &Keypair) -> Pubkey { + MINT +} + +/// Mint tokens to a destination account (admin is the mint authority). +async fn mint_to( + ctx: &mut ProgramTestContext, + admin: &Keypair, + mint: &Pubkey, + dest: &Pubkey, + amount: u64, +) { + let ix = spl_token::instruction::mint_to(&spl_token::ID, mint, dest, &admin.pubkey(), &[], amount) + .expect("mint_to"); + run_ix(ctx, admin, ix).await.expect("mint_to"); +} + +/// New bettor: lamports account + associated token account + token supply. +async fn create_bettor( + ctx: &mut ProgramTestContext, + admin: &Keypair, + mint: &Pubkey, +) -> (Keypair, Pubkey) { + let bettor = Keypair::new(); + airdrop_to(ctx, &bettor.pubkey(), 100_000_000_000).await; + let ata = spl_associated_token_account::get_associated_token_address(&bettor.pubkey(), mint); + let ix = spl_associated_token_account::instruction::create_associated_token_account( + &admin.pubkey(), + &bettor.pubkey(), + mint, + &spl_token::ID, + ); + run_ix(ctx, admin, ix).await.expect("create ata"); + mint_to(ctx, admin, mint, &ata, BETTOR_TOKEN_SUPPLY).await; + (bettor, ata) +} + +/// Read the `amount` field of a classic SPL token account (offset 64). +async fn read_token_balance(ctx: &mut ProgramTestContext, addr: &Pubkey) -> u64 { + let data = read_account_data(&ctx.banks_client, addr).await; + u64::from_le_bytes(data[64..72].try_into().expect("amount offset")) +} + +/// close_bet pays out by moving tokens out of the vault; the vault PDA signs +/// that transfer CPI inside the program, so the transaction needs only the +/// relayer's signature. +async fn run_close_tx( + ctx: &mut ProgramTestContext, + signer: &Keypair, + ix: &Instruction, +) -> Result<(), String> { + // The vault PDA signs the payout CPI inside the program (via signer_seeds); + // the transaction itself needs only the relayer's signature. + let tx = Transaction::new_signed_with_payer( + std::slice::from_ref(ix), + Some(&signer.pubkey()), + &[signer], + ctx.last_blockhash, + ); + ctx.banks_client + .process_transaction(tx) + .await + .map(|_| ()) + .map_err(|e| format!("{:?}", e)) } async fn create_up_bet( ctx: &mut ProgramTestContext, side: u64, -) -> (Keypair, u64, u64) { - let bettor = Keypair::new(); - airdrop_to(ctx, &bettor.pubkey(), 100_000_000_000).await; + mint: &Pubkey, + admin: &Keypair, +) -> (Keypair, Pubkey, u64, u64) { + let (bettor, ata) = create_bettor(ctx, admin, mint).await; set_clock(ctx, START_TIME).await; let amount = MIN_AMOUNT; let entry_price = 100_000_000u64; - let create_ix = make_create_ix(0, side, amount, entry_price, bettor.pubkey()); + let create_ix = make_create_ix(0, side, amount, entry_price, bettor.pubkey(), &ata); run_ix(ctx, &bettor, create_ix).await.expect("create_bet"); - (bettor, amount, entry_price) + (bettor, ata, amount, entry_price) } #[tokio::test] @@ -200,13 +361,20 @@ async fn initialize_creates_global() { } #[tokio::test] -async fn create_bet_escrows_sol() { +async fn create_bet_escrows_tokens() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; - let (bettor, amount, entry_price) = create_up_bet(&mut ctx, 0).await; + let mint = create_token_env(&mut ctx, &admin).await; + let (bettor, ata, amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; - let vault_balance = ctx.banks_client.get_balance(global_pda()).await.unwrap(); - assert!(vault_balance >= amount); + assert_eq!( + read_token_balance(&mut ctx, &vault_pda()).await, + VAULT_TOKEN_SUPPLY + amount + ); + assert_eq!( + read_token_balance(&mut ctx, &ata).await, + BETTOR_TOKEN_SUPPLY - amount + ); let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await); assert_eq!(b.bettor, bettor.pubkey()); assert_eq!(b.side, 0); @@ -222,18 +390,27 @@ async fn create_bet_escrows_sol() { async fn close_bet_user_wins_up() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; - let (bettor, amount, entry_price) = create_up_bet(&mut ctx, 0).await; + let mint = create_token_env(&mut ctx, &admin).await; + let (bettor, ata, amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; let exit_price = entry_price + 1_000; - let before = ctx.banks_client.get_balance(bettor.pubkey()).await.unwrap(); + let before = read_token_balance(&mut ctx, &ata).await; let payout = amount * MULTIPLIER_BPS / 10_000; - fund_vault(&mut ctx, 10 * amount).await; - let close_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey()); - run_ix(&mut ctx, &admin, close_ix).await.expect("close_bet"); + run_close_tx( + &mut ctx, + &admin, + &make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey(), &ata), + ) + .await + .expect("close_bet"); - assert_eq!(ctx.banks_client.get_balance(bettor.pubkey()).await.unwrap(), before + payout); + assert_eq!(read_token_balance(&mut ctx, &ata).await, before + payout); + assert_eq!( + read_token_balance(&mut ctx, &vault_pda()).await, + VAULT_TOKEN_SUPPLY + amount - payout + ); let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await); assert_eq!(b.status, 1); assert_eq!(b.exit_price, exit_price); @@ -243,16 +420,22 @@ async fn close_bet_user_wins_up() { async fn close_bet_house_wins_down() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; - let (bettor, _amount, entry_price) = create_up_bet(&mut ctx, 1).await; + let mint = create_token_env(&mut ctx, &admin).await; + let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 1, &mint, &admin).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; let exit_price = entry_price + 1_000; // side=DOWN but price rose -> lose - let before = ctx.banks_client.get_balance(bettor.pubkey()).await.unwrap(); + let before = read_token_balance(&mut ctx, &ata).await; - let close_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey()); - run_ix(&mut ctx, &admin, close_ix).await.expect("close_bet"); + run_close_tx( + &mut ctx, + &admin, + &make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey(), &ata), + ) + .await + .expect("close_bet"); - assert_eq!(ctx.banks_client.get_balance(bettor.pubkey()).await.unwrap(), before); + assert_eq!(read_token_balance(&mut ctx, &ata).await, before); assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 2); } @@ -260,23 +443,23 @@ async fn close_bet_house_wins_down() { async fn close_bet_equal_price_stays_open() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; - let (bettor, amount, entry_price) = create_up_bet(&mut ctx, 0).await; + let mint = create_token_env(&mut ctx, &admin).await; + let (bettor, ata, amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; - let before = ctx.banks_client.get_balance(bettor.pubkey()).await.unwrap(); + let before = read_token_balance(&mut ctx, &ata).await; let payout = amount * MULTIPLIER_BPS / 10_000; - fund_vault(&mut ctx, 10 * amount).await; - let equal_ix = make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey()); - run_ix(&mut ctx, &admin, equal_ix).await.expect("equal close"); + let equal_ix = make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey(), &ata); + run_close_tx(&mut ctx, &admin, &equal_ix).await.expect("equal close"); assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 0); - assert_eq!(ctx.banks_client.get_balance(bettor.pubkey()).await.unwrap(), before); + assert_eq!(read_token_balance(&mut ctx, &ata).await, before); let exit_price = entry_price + 1_000; - let second_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey()); - run_ix(&mut ctx, &admin, second_ix).await.expect("second close"); + let second_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey(), &ata); + run_close_tx(&mut ctx, &admin, &second_ix).await.expect("second close"); assert_eq!( - ctx.banks_client.get_balance(bettor.pubkey()).await.unwrap(), + read_token_balance(&mut ctx, &ata).await, before + payout ); assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 1); @@ -286,37 +469,40 @@ async fn close_bet_equal_price_stays_open() { async fn close_bet_already_finalized() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; - let (bettor, amount, entry_price) = create_up_bet(&mut ctx, 0).await; + let mint = create_token_env(&mut ctx, &admin).await; + let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; let win_price = entry_price + 500; - fund_vault(&mut ctx, 10 * amount).await; - run_ix(&mut ctx, &admin, make_close_ix(0, win_price, admin.pubkey(), bettor.pubkey())) - .await - .expect("first close"); - - let res = run_ix( + run_close_tx( &mut ctx, &admin, - make_close_ix(0, win_price + 1, admin.pubkey(), bettor.pubkey()), + &make_close_ix(0, win_price, admin.pubkey(), bettor.pubkey(), &ata), + ) + .await + .expect("first close"); + + let res = run_close_tx( + &mut ctx, + &admin, + &make_close_ix(0, win_price + 1, admin.pubkey(), bettor.pubkey(), &ata), ) .await; assert!(res.is_err(), "expected close after finalization to be rejected"); let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await); assert_eq!(b.status, 1); assert_eq!(b.exit_price, win_price); - assert_eq!(b.status, 1); - assert_eq!(b.exit_price, win_price); } #[tokio::test] async fn close_bet_before_expiry_rejected() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; - let (bettor, _amount, entry_price) = create_up_bet(&mut ctx, 0).await; + let mint = create_token_env(&mut ctx, &admin).await; + let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; - let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey()); - let res = run_ix(&mut ctx, &admin, close_ix).await; + let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey(), &ata); + let res = run_close_tx(&mut ctx, &admin, &close_ix).await; assert!(res.is_err(), "expected premature close to be rejected"); assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 0); } @@ -325,13 +511,14 @@ async fn close_bet_before_expiry_rejected() { async fn close_bet_wrong_relayer_rejected() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; - let (bettor, _amount, entry_price) = create_up_bet(&mut ctx, 0).await; + let mint = create_token_env(&mut ctx, &admin).await; + let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; let attacker = Keypair::new(); airdrop_to(&mut ctx, &attacker.pubkey(), 100_000_000_000).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; - let close_ix = make_close_ix(0, entry_price + 500, attacker.pubkey(), bettor.pubkey()); - let res = run_ix(&mut ctx, &attacker, close_ix).await; + let close_ix = make_close_ix(0, entry_price + 500, attacker.pubkey(), bettor.pubkey(), &ata); + let res = run_close_tx(&mut ctx, &attacker, &close_ix).await; assert!(res.is_err(), "expected wrong relayer to be rejected"); assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 0); } @@ -340,32 +527,34 @@ async fn close_bet_wrong_relayer_rejected() { async fn close_bet_replay_rejected() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; - let (bettor, _amount, entry_price) = create_up_bet(&mut ctx, 0).await; + let mint = create_token_env(&mut ctx, &admin).await; + let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; - fund_vault(&mut ctx, 10 * MIN_AMOUNT).await; - let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey()); - run_ix(&mut ctx, &admin, close_ix).await.expect("first close"); - let before = ctx.banks_client.get_balance(bettor.pubkey()).await.unwrap(); - let res = run_ix( + let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey(), &ata); + run_close_tx(&mut ctx, &admin, &close_ix).await.expect("first close"); + let before = read_token_balance(&mut ctx, &ata).await; + let res = run_close_tx( &mut ctx, &admin, - make_close_ix(0, entry_price + 501, admin.pubkey(), bettor.pubkey()), + &make_close_ix(0, entry_price + 501, admin.pubkey(), bettor.pubkey(), &ata), ) .await; assert!(res.is_err(), "expected replay close to be rejected"); - assert_eq!(ctx.banks_client.get_balance(bettor.pubkey()).await.unwrap(), before); + assert_eq!(read_token_balance(&mut ctx, &ata).await, before); } #[tokio::test] async fn create_bet_rejects_out_of_range() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; - let bettor = Keypair::new(); - airdrop_to(&mut ctx, &bettor.pubkey(), 100_000_000_000).await; + let mint = create_token_env(&mut ctx, &admin).await; + let (bettor, ata) = create_bettor(&mut ctx, &admin, &mint).await; set_clock(&mut ctx, START_TIME).await; - let ix = make_create_ix(0, 0, 1, 100_000_000u64, bettor.pubkey()); + let vault_before = read_token_balance(&mut ctx, &vault_pda()).await; + let ix = make_create_ix(0, 0, 1, 100_000_000u64, bettor.pubkey(), &ata); let res = run_ix(&mut ctx, &bettor, ix).await; assert!(res.is_err(), "expected below-min bet to be rejected"); + assert_eq!(read_token_balance(&mut ctx, &vault_pda()).await, vault_before); } \ No newline at end of file diff --git a/relayer/idl/smart_updown.json b/relayer/idl/smart_updown.json index 74b4b4f..6401805 100644 --- a/relayer/idl/smart_updown.json +++ b/relayer/idl/smart_updown.json @@ -10,7 +10,14 @@ { "name": "close_bet", "discriminator": [ - 2 + 185, + 206, + 13, + 184, + 176, + 108, + 140, + 107 ], "accounts": [ { @@ -57,15 +64,54 @@ } }, { - "name": "bettor", - "docs": [ - "CHECK: recipient must be the bet's bettor and is validated in the handler." - ], - "writable": true + "name": "bettor" }, { "name": "clock", "address": "SysvarC1ock11111111111111111111111111111111" + }, + { + "name": "vault_token", + "docs": [ + "Program-owned token vault: PDA([GLOBAL_SEED, VAULT_SEED]); payouts are paid out from here." + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108 + ] + }, + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116 + ] + } + ] + } + }, + { + "name": "bettor_token", + "docs": [ + "Bettor's SPL-token account; the payout is sent here." + ], + "writable": true + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" } ], "args": [ @@ -82,7 +128,14 @@ { "name": "create_bet", "discriminator": [ - 1 + 197, + 42, + 153, + 2, + 59, + 63, + 143, + 246 ], "accounts": [ { @@ -136,6 +189,49 @@ { "name": "system_program", "address": "11111111111111111111111111111111" + }, + { + "name": "bettor_token", + "docs": [ + "Bettor's SPL-token account (our \"fanti\" mint); bet amount is pulled from here." + ], + "writable": true + }, + { + "name": "vault_token", + "docs": [ + "Program-owned token vault: PDA([GLOBAL_SEED, VAULT_SEED]); bet amounts are escrowed here." + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108 + ] + }, + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116 + ] + } + ] + } + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" } ], "args": [ @@ -160,7 +256,14 @@ { "name": "initialize", "discriminator": [ - 0 + 175, + 175, + 109, + 31, + 13, + 152, + 155, + 237 ], "accounts": [ { @@ -214,7 +317,14 @@ { "name": "update", "discriminator": [ - 3 + 219, + 200, + 88, + 176, + 158, + 63, + 253, + 127 ], "accounts": [ { @@ -380,10 +490,9 @@ "types": [ { "name": "Bet", - "serialization": "bytemuck", - "repr": { - "kind": "c" - }, + "docs": [ + "Open bet. `amount` is in token base units (decimals = 6), not lamports." + ], "type": { "kind": "struct", "fields": [ @@ -488,10 +597,13 @@ }, { "name": "Global", - "serialization": "bytemuck", - "repr": { - "kind": "c" - }, + "docs": [ + "Global game state.", + "", + "All money-related fields are denominated in **SPL-token base units**", + "(our \"fanti\" token, decimals = 6), not in lamports:", + "`min_amount`/`max_amount` bound a bet amount in token units." + ], "type": { "kind": "struct", "fields": [ @@ -541,6 +653,15 @@ "name": "GLOBAL_SEED", "type": "bytes", "value": "[103, 108, 111, 98, 97, 108]" + }, + { + "name": "VAULT_SEED", + "docs": [ + "Vault PDA seed: the token vault lives at PDA([GLOBAL_SEED, VAULT_SEED]).", + "It cannot reuse GLOBAL_SEED alone — that PDA address is the `global` state account itself." + ], + "type": "bytes", + "value": "[118, 97, 117, 108, 116]" } ] -} \ No newline at end of file +}