Compare commits
5 Commits
0744ac3163
...
9ca469a075
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
9ca469a075 | ||
|
|
5981d3f871 | ||
|
|
fb0c5ad3f8 | ||
|
|
e5fe925023 | ||
|
|
b83e8c3979 |
102
Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md
Normal file
102
Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
Аудит безопасности Solana-программ SHiNE
|
||||||
|
|
||||||
|
Проверены три программы в shine-solana/shine/programs/:
|
||||||
|
- shine_login_guard (183 строки) — stateless-классификатор логинов
|
||||||
|
- shine_users (1035 строк) — реестр пользователей, PDA-записи, подписи, экономика лимитов
|
||||||
|
- shine_payments (1330 строк) — очереди тикетов, выплаты, оракул Pyth
|
||||||
|
|
||||||
|
Общая инженерная культура высокая: везде checked_*-арифметика, overflow-checks = true, ручная верификация ed25519 через sysvar инструкций, аккуратный bounds-checked парсинг. Но есть две критические дыры одного класса — отсутствие проверки адреса PDA, которые позволяют обойти всю экономику и украсть средства из вольта.
|
||||||
|
|
||||||
|
---
|
||||||
|
🔴 CRITICAL #1 — shine_users: economy-config PDA не валидируется → бесплатная регистрация и бесконечный лимит
|
||||||
|
|
||||||
|
В process_create_user_pda (строка 448) и process_update_user_pda (строка 525):
|
||||||
|
|
||||||
|
let economy = read_users_economy_config(users_economy_config_pda)?;
|
||||||
|
|
||||||
|
А read_users_economy_config (строки 633–643) не проверяет ни адрес, ни владельца аккаунта — просто читает байты:
|
||||||
|
|
||||||
|
fn read_users_economy_config(pda: &AccountInfo) -> Result<...> {
|
||||||
|
let raw = read_pda_all(pda)?; // try_borrow_data, без проверок
|
||||||
|
require!(raw.len() >= 25, ...);
|
||||||
|
Ok(UsersEconomyConfigState { version: raw[0], registration_fee_lamports: ..., ... })
|
||||||
|
}
|
||||||
|
|
||||||
|
Сравните: в init/update_economy_config адрес проверяется через find_users_economy_config_pda (строки 382, 414), а в create/update — нет.
|
||||||
|
|
||||||
|
Эксплуатация. Атакующий создаёт любой свой аккаунт с 25 байтами произвольного содержимого и передаёт его как users_economy_config_pda:
|
||||||
|
- registration_fee_lamports = 0 → регистрация без оплаты;
|
||||||
|
- start_bonus_limit = u64::MAX → запись пользователя сразу получает гигантский paid_limit_bytes (бесплатная безлимитная квота хранилища/блокчейна);
|
||||||
|
- lamports_per_limit_step = 0 → бесплатное пополнение лимита на любую величину.
|
||||||
|
|
||||||
|
Комиссия (когда она ненулевая) уходит в правильный вольт — validate_inflow_vault это проверяет — но атакующему достаточно обнулить комиссию и накрутить лимит. Это полный обход экономической модели программы.
|
||||||
|
|
||||||
|
Фикс: в обеих функциях перед чтением добавить
|
||||||
|
require_keys_eq!(find_users_economy_config_pda(program_id).0, *users_economy_config_pda.key, ShineUsersError::InvalidPdaAddress);
|
||||||
|
require!(users_economy_config_pda.owner == program_id, ShineUsersError::InvalidPdaAddress);
|
||||||
|
|
||||||
|
---
|
||||||
|
🔴 CRITICAL #2 — shine_payments: singleton-PDA не привязаны к адресу → кража из вольта в step_payout
|
||||||
|
|
||||||
|
ensure_expected_pdas вызывается только в process_init (строка 519). Во всех остальных инструкциях config_pda, coef_limit_pda, queues_pda читаются через read_state, который проверяет только владельца (*pda.owner == id()), но не адрес:
|
||||||
|
|
||||||
|
fn read_state<T>(pda) -> ... {
|
||||||
|
require!(!is_uninitialized_account(pda), ...);
|
||||||
|
require_keys_eq!(*pda.owner, id(), ...); // только owner, адрес НЕ проверяется
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
Программа владеет аккаунтами нескольких типов (config, coef_limit, queues, vault, tickets, manager_allowances), и часть их содержимого атакующий контролирует напрямую. В TicketState поле recipient_wallet (32 байта по смещению 11) — полностью произвольные байты из BuyTicketArgs, это не обязан быть валидный ключ.
|
||||||
|
|
||||||
|
Эксплуатация (конкретная, практически реализуемая). В process_step_payout (строки 783–846) coef_limit_pda не проверяется по адресу. Раскладка CoefLimitState при декодировании: version=byte0, coef_ppm=[1..9], limit=[9..17], call_reward_lamports=[17..25]. Байты [17..25] тикета попадают на recipient_wallet[6..14] — их атакующий задаёт сам при покупке тикета. То есть:
|
||||||
|
|
||||||
|
1. Атакующий покупает тикет с recipient_wallet, чьи байты 6..14 кодируют огромный call_reward_lamports.
|
||||||
|
2. В step_payout подставляет этот тикет как coef_limit_pda.
|
||||||
|
3. transfer_from_vault(inflow_vault_pda, signer, coef_limit.call_reward_lamports) (строка 840) переводит подписанту (атакующему) почти весь доступный баланс вольта, который должен был накопиться для DAO.
|
||||||
|
|
||||||
|
version тикета = 1, decode значение версии не проверяет, поэтому подстановка проходит.
|
||||||
|
|
||||||
|
Подстановка config_pda для обхода DAO-авторизации в update_coef_limit/grant_manager_limits теоретически тоже возможна, но непрактична: там нужный dao_wallet пересекается со структурными полями тикета, и подбор потребовал бы грайндинга ~80 бит ключа. А вот путь через coef_limit/step_payout — реальная кража.
|
||||||
|
|
||||||
|
Фикс: во всех инструкциях, принимающих singleton-PDA, проверять адрес, например вызывать ensure_expected_pdas-подобную проверку (require_keys_eq!(find_single_pda(program_id, SEED).0, *pda.key, …)) для config/coef_limit/queues/inflow_vault, а для queues_pda в change_ticket_recipient — тоже.
|
||||||
|
|
||||||
|
---
|
||||||
|
🟠 MEDIUM — shine_payments: слабая валидация оракула Pyth
|
||||||
|
|
||||||
|
read_sol_usd_price / parse_pyth_price_update_v2 (строки 1038–1075):
|
||||||
|
|
||||||
|
1. Не проверяется владелец аккаунта цены (что он принадлежит Pyth receiver). Спасает только то, что адрес жёстко закреплён константой PYTH_SOL_USD_ACCOUNT. Это делает подмену невозможной, но защита держится на одном инварианте.
|
||||||
|
2. feed_id не проверяется. Константа PYTH_SOL_USD_FEED_ID объявлена в settings.rs, но в коде нигде не используется — программа доверяет, что в закреплённом аккаунте лежит именно SOL/USD.
|
||||||
|
3. Фиксированные смещения (73/89/93) предполагают VerificationLevel::Full. Borsh сериализует enum переменной длиной: Partial{num_signatures} занимает 2 байта вместо 1, что сдвигает все поля на 1 байт и приведёт к чтению мусорной цены. Уровень верификации не проверяется.
|
||||||
|
4. Confidence (conf) игнорируется — нет защиты от широкого ценового интервала.
|
||||||
|
|
||||||
|
Проверка возраста цены (ORACLE_MAX_AGE_SECS = 120) есть и сделана корректно. Рекомендация: проверять владельца аккаунта, сверять feed_id с константой и валидировать verification_level == Full (или парсить через официальный pyth_solana_receiver_sdk, который уже завендорен в .vendor/).
|
||||||
|
|
||||||
|
---
|
||||||
|
🟡 LOW — DoS через предсказуемые адреса тикетов
|
||||||
|
|
||||||
|
is_uninitialized_account (строка 1195) считает аккаунт неинициализированным только если lamports() == 0. Адреса тикетов детерминированы (queue_seed + index), а индекс последователен и предсказуем. Любой может заранее перевести немного лампортов на адрес следующего тикета — тогда create_pda_account упадёт (PdaAlreadyExists / ошибка create_account), заблокировав покупку/добавление тикета. Это griefing-DoS, не кража. Митигировать можно паттерном «create поверх предзаполненного» (allocate + assign + добор ренты) вместо system_instruction::create_account.
|
||||||
|
|
||||||
|
---
|
||||||
|
✅ Что проверено и сделано корректно
|
||||||
|
|
||||||
|
- Верификация подписей ed25519 в shine_users (строки 885–922): строго пинятся относительные индексы инструкций (−1/−2), требуется num_signatures == 1, все три ix-index == u16::MAX (данные внутри самой ed25519-инструкции — нельзя указать на чужую инструкцию), и сверяются pubkey/signature/message. Сделано грамотно.
|
||||||
|
- Цепочка версий записи (version == record_number+1, prev_hash == hash(old)) — корректная защита от replay (строки 535–536).
|
||||||
|
- Авторизация обновления записи завязана на ed25519-подпись root_key, а не на подписанта-плательщика — случайный аккаунт обновить чужую запись не может.
|
||||||
|
- Монотонность used_bytes/last_block_number и used_bytes <= paid_limit_bytes (строки 979–986).
|
||||||
|
- inflow_vault валидируется по derive из программы payments (строки 988–993).
|
||||||
|
- transfer_from_vault сохраняет рент-экземпт (вычитает minimum_balance через available_vault_lamports).
|
||||||
|
- init обеих программ безопасен к front-run: значения берутся из констант, а не от вызывающего; повторная инициализация заблокирована проверкой «uninitialized».
|
||||||
|
- Арифметика: overflow-checks = true в release-профиле + повсеместные checked_add/sub/mul. Парсинг везде с проверкой границ.
|
||||||
|
- manager_allowance PDA — единственный из payments, чей адрес проверяется корректно во всех путях (строки 645–646, 739–740).
|
||||||
|
- shine_login_guard — stateless, без аккаунтов и средств; рисков безопасности не несёт.
|
||||||
|
|
||||||
|
---
|
||||||
|
Приоритет действий
|
||||||
|
|
||||||
|
1. Critical #1 — добавить проверку адреса+владельца economy-config в create/update shine_users. Тривиальный фикс, помощник find_users_economy_config_pda уже есть.
|
||||||
|
2. Critical #2 — добавить проверку адресов всех singleton-PDA во все инструкции shine_payments (минимум coef_limit_pda/config_pda/queues_pda в step_payout и change_ticket_recipient).
|
||||||
|
3. Medium — ужесточить парсинг Pyth (owner + feed_id + verification_level), либо перейти на завендоренный SDK.
|
||||||
|
4. Low — учесть griefing-DoS на предсказуемых адресах тикетов.
|
||||||
|
|
||||||
|
Обе критические находки относятся к одному классу (Solana «missing ownership/address check» — самая частая категория эксплойтов), их стоит закрыть до любого деплоя в mainnet. Изменений в код я не вносил — это только анализ; готов подготовить патчи на оба критических пункта, если подтвердите.
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.149
|
client.version=1.2.150
|
||||||
server.version=1.2.141
|
server.version=1.2.142
|
||||||
|
|||||||
142
shine-solana/shine/Cargo.lock
generated
142
shine-solana/shine/Cargo.lock
generated
@ -192,9 +192,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.98"
|
version = "1.0.102"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayref"
|
name = "arrayref"
|
||||||
@ -377,6 +377,9 @@ name = "bytemuck"
|
|||||||
version = "1.23.0"
|
version = "1.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c"
|
checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck_derive",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck_derive"
|
name = "bytemuck_derive"
|
||||||
@ -389,6 +392,12 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.23"
|
version = "1.2.23"
|
||||||
@ -410,13 +419,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "common"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anchor-lang",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "console_error_panic_hook"
|
name = "console_error_panic_hook"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@ -522,6 +524,15 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fast-math"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2465292146cdfc2011350fe3b1c616ac83cf0faeedb33463ba1c332ed8948d66"
|
||||||
|
dependencies = [
|
||||||
|
"ieee754",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "feature-probe"
|
name = "feature-probe"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@ -616,6 +627,21 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ieee754"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9007da9cacbd3e6343da136e98b0d2df013f553d35bdec8b518f07bea768e19c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
@ -628,9 +654,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.15"
|
version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
@ -740,6 +766,20 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-iter",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@ -750,6 +790,15 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-complex"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-derive"
|
name = "num-derive"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@ -770,6 +819,28 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-iter"
|
||||||
|
version = "0.1.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-rational"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@ -850,6 +921,40 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyth-solana-receiver-sdk"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f07e92abfc18154532ed3dabaa7dac8e693b9925bfe28b2915bc8f8c1540ca0"
|
||||||
|
dependencies = [
|
||||||
|
"anchor-lang",
|
||||||
|
"bytemuck_derive",
|
||||||
|
"hex",
|
||||||
|
"pythnet-sdk",
|
||||||
|
"solana-program",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pythnet-sdk"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "498d20fd330277697aaee92f341bdabdb4695b10e05f054157a18ad8b7746a17"
|
||||||
|
dependencies = [
|
||||||
|
"anchor-lang",
|
||||||
|
"bincode",
|
||||||
|
"borsh 0.10.4",
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder",
|
||||||
|
"fast-math",
|
||||||
|
"hex",
|
||||||
|
"rustc_version",
|
||||||
|
"serde",
|
||||||
|
"sha3",
|
||||||
|
"slow_primes",
|
||||||
|
"solana-program",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.40"
|
version = "1.0.40"
|
||||||
@ -956,9 +1061,9 @@ checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.20"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
@ -1058,6 +1163,8 @@ dependencies = [
|
|||||||
name = "shine_payments"
|
name = "shine_payments"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anchor-lang",
|
||||||
|
"pyth-solana-receiver-sdk",
|
||||||
"solana-program",
|
"solana-program",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1074,6 +1181,15 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slow_primes"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "58267dd2fbaa6dceecba9e3e106d2d90a2b02497c0e8b01b8759beccf5113938"
|
||||||
|
dependencies = [
|
||||||
|
"num",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"programs/common",
|
|
||||||
"programs/shine_login_guard",
|
"programs/shine_login_guard",
|
||||||
"programs/shine_users",
|
"programs/shine_users",
|
||||||
"programs/shine_payments",
|
"programs/shine_payments",
|
||||||
|
|||||||
@ -350,10 +350,18 @@ next_index = tickets_paid + 1
|
|||||||
Проверки oracle:
|
Проверки oracle:
|
||||||
|
|
||||||
- передан именно тот oracle account, что указан в settings;
|
- передан именно тот oracle account, что указан в settings;
|
||||||
- feed id совпадает с ожидаемым;
|
- owner oracle-аккаунта совпадает с Pyth Solana Receiver program;
|
||||||
|
- feed id совпадает с ожидаемым `PYTH_SOL_USD_FEED_ID`;
|
||||||
|
- verification level должен быть `Full`;
|
||||||
- цена не старше `ORACLE_MAX_AGE_SECS`;
|
- цена не старше `ORACLE_MAX_AGE_SECS`;
|
||||||
|
- доверительный интервал (`conf`) не должен быть шире `ORACLE_MAX_CONFIDENCE_PPM`;
|
||||||
- цена положительная и корректно переводима в ratio.
|
- цена положительная и корректно переводима в ratio.
|
||||||
|
|
||||||
|
Реализация чтения:
|
||||||
|
|
||||||
|
- для декодирования price update используется официальный open-source `pyth-solana-receiver-sdk`;
|
||||||
|
- ручной парсинг по фиксированным offset-ам не используется.
|
||||||
|
|
||||||
Внутренние преобразования:
|
Внутренние преобразования:
|
||||||
|
|
||||||
- `lamports -> usd_cents` делаются с округлением вниз;
|
- `lamports -> usd_cents` делаются с округлением вниз;
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "common"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anchor-lang = "0.31.1"
|
|
||||||
|
|
||||||
|
|
||||||
[features]
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
//! Единый деплой-конфиг проекта SHINE.
|
|
||||||
//! Здесь хранятся адреса и параметры, которые зависят от окружения деплоя.
|
|
||||||
|
|
||||||
// =========================
|
|
||||||
// Program IDs
|
|
||||||
// =========================
|
|
||||||
|
|
||||||
/// `SHINE_PAYMENTS_PROGRAM_ID` — адрес программы `shine_payments` для текущего окружения.
|
|
||||||
pub const SHINE_PAYMENTS_PROGRAM_ID: &str = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
|
|
||||||
|
|
||||||
/// `SHINE_USERS_PROGRAM_ID` — адрес программы `shine_users` для текущего окружения.
|
|
||||||
pub const SHINE_USERS_PROGRAM_ID: &str = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
|
|
||||||
|
|
||||||
/// `SHINE_LOGIN_GUARD_PROGRAM_ID` — адрес программы проверки платных логинов.
|
|
||||||
pub const SHINE_LOGIN_GUARD_PROGRAM_ID: &str = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
|
|
||||||
|
|
||||||
// =========================
|
|
||||||
// DAO / роли управления
|
|
||||||
// =========================
|
|
||||||
|
|
||||||
/// `DAO_AUTHORITY` — кошелек DAO/управления, который имеет право менять защищенные настройки.
|
|
||||||
pub const DAO_AUTHORITY: &str = "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P";
|
|
||||||
|
|
||||||
/// `DAO_TREASURY_WALLET` — кошелек казны DAO для поступления DAO-части выплат в `shine_payments`.
|
|
||||||
pub const DAO_TREASURY_WALLET: &str = "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P";
|
|
||||||
|
|
||||||
// =========================
|
|
||||||
// Комиссии / получатели
|
|
||||||
// =========================
|
|
||||||
|
|
||||||
/// `REGISTRATION_FEE_RECEIVER` — кошелек получателя комиссии за регистрацию в `shine_users`.
|
|
||||||
pub const REGISTRATION_FEE_RECEIVER: &str = "9vXFoN9ngfN1gpqQ3HT5n3y9Wp2r7HnSQckirgwVwWwb";
|
|
||||||
|
|
||||||
// =========================
|
|
||||||
// Оракул (Pyth SOL/USD)
|
|
||||||
// =========================
|
|
||||||
|
|
||||||
/// `PYTH_SOL_USD_FEED_ID` — feed id Pyth для пары SOL/USD (используется для проверки feed внутри аккаунта).
|
|
||||||
pub const PYTH_SOL_USD_FEED_ID: &str =
|
|
||||||
"0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d";
|
|
||||||
|
|
||||||
/// `PYTH_SOL_USD_ACCOUNT` — адрес Solana-аккаунта обновлений цены Pyth для SOL/USD.
|
|
||||||
pub const PYTH_SOL_USD_ACCOUNT: &str = "7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE";
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
pub mod utils;
|
|
||||||
pub mod deploy_config;
|
|
||||||
@ -1,359 +0,0 @@
|
|||||||
use anchor_lang::prelude::*;
|
|
||||||
use anchor_lang::solana_program::{program::invoke_signed, system_instruction};
|
|
||||||
|
|
||||||
/// сдесь коды всех ошибок
|
|
||||||
|
|
||||||
#[error_code]
|
|
||||||
pub enum ErrCode {
|
|
||||||
/// Система уже инициализирована и не может быть инициализирована повторно!
|
|
||||||
#[msg("Система уже инициализирована и не может быть инициализирована повторно!")]
|
|
||||||
SystemAlreadyInitialized = 1000,
|
|
||||||
|
|
||||||
#[msg("PDA не содержит данных или не инициализирован")]
|
|
||||||
EmptyPdaData = 1002,
|
|
||||||
|
|
||||||
#[msg("Пользователь уже зарегистрирован")]
|
|
||||||
UserAlreadyExists = 1003,
|
|
||||||
|
|
||||||
#[msg("Некорректный логин")]
|
|
||||||
InvalidLogin = 1004,
|
|
||||||
|
|
||||||
#[msg("Не совпадает PDA адрес")]
|
|
||||||
InvalidPdaAddress = 1006,
|
|
||||||
|
|
||||||
#[msg("Формат данных не поддерживается")]
|
|
||||||
UnsupportedFormat = 1011,
|
|
||||||
|
|
||||||
#[msg("Ошибка при десериализации")]
|
|
||||||
DeserializationError = 1012,
|
|
||||||
|
|
||||||
/// PDA уже существует, создание невозможно
|
|
||||||
#[msg("PDA-аккаунт уже существует и не может быть создан повторно.")]
|
|
||||||
PdaAlreadyExists = 1009,
|
|
||||||
|
|
||||||
#[msg("Подписавший не совпадает с ожидаемым пользователем (это потому что пока временно можно регистрировать пользователя с другово аккаунта")]
|
|
||||||
InvalidSigner = 1005,
|
|
||||||
|
|
||||||
/// Не получилось создат ьпользователя, система уже перегружена, попробуйте поззже!"
|
|
||||||
#[msg("Не получилось создать пользователя, система уже перегружена, попробуйте поззже!")]
|
|
||||||
NoSuitableIdPda = 1010,
|
|
||||||
|
|
||||||
#[msg("Невалидная цифровая подпись записи")]
|
|
||||||
InvalidSignature = 1013,
|
|
||||||
|
|
||||||
#[msg("Невалидный формат записи")]
|
|
||||||
InvalidRecordFormat = 1014,
|
|
||||||
|
|
||||||
#[msg("Невалидная длина записи")]
|
|
||||||
InvalidRecordLength = 1015,
|
|
||||||
|
|
||||||
#[msg("Невалидные данные записи")]
|
|
||||||
InvalidRecordData = 1016,
|
|
||||||
|
|
||||||
#[msg("Невалидный хэш предыдущей версии")]
|
|
||||||
InvalidPrevHash = 1017,
|
|
||||||
|
|
||||||
#[msg("Попытка изменить неизменяемое поле")]
|
|
||||||
ImmutableFieldChanged = 1018,
|
|
||||||
|
|
||||||
#[msg("Попытка уменьшить лимит/баланс")]
|
|
||||||
BalanceDecrease = 1019,
|
|
||||||
|
|
||||||
#[msg("Невалидная версия записи")]
|
|
||||||
InvalidVersion = 1020,
|
|
||||||
|
|
||||||
#[msg("Размер записи превышает допустимый")]
|
|
||||||
RecordTooLarge = 1021,
|
|
||||||
|
|
||||||
#[msg("Переполнение при вычислении")]
|
|
||||||
MathOverflow = 1022,
|
|
||||||
|
|
||||||
#[msg("Неверный адрес получателя комиссии")]
|
|
||||||
InvalidFeeReceiver = 1023,
|
|
||||||
|
|
||||||
#[msg("Пополнение лимита должно быть кратно шагу")]
|
|
||||||
InvalidLimitIncrement = 1024,
|
|
||||||
|
|
||||||
#[msg("Невалидная magic-сигнатура записи")]
|
|
||||||
InvalidRecordMagic = 1025,
|
|
||||||
|
|
||||||
#[msg("Логин относится к платным и требует отдельной покупки через DAO")]
|
|
||||||
PremiumLogin = 1026,
|
|
||||||
|
|
||||||
#[msg("Некорректный ответ программы проверки логина")]
|
|
||||||
InvalidLoginGuardResponse = 1027,
|
|
||||||
|
|
||||||
#[msg("Логин использует брендовый термин и требует дополнительной верификации")]
|
|
||||||
TrademarkLoginRequiresReview = 1028,
|
|
||||||
}
|
|
||||||
|
|
||||||
///----------------------------------------------------------------------------------------------------------
|
|
||||||
/// Базовые функции для работы с PDA
|
|
||||||
///----------------------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Создаёт PDA аккаунт (если его ещё нет), и записывает в него массив байт.
|
|
||||||
///
|
|
||||||
/// Аргументы:
|
|
||||||
/// - `pda_account`: аккаунт, куда записываем
|
|
||||||
/// - `signer`: кто платит за создание (обычно пользователь)
|
|
||||||
/// - `program_id`: адрес текущей программы
|
|
||||||
/// - `seeds`: слайс сидов, по которым создавался PDA
|
|
||||||
/// - `data`: байты для записи
|
|
||||||
/// - `space`: желаемый размер аккаунта
|
|
||||||
pub fn create_and_write_pda<'info>(
|
|
||||||
pda_account: &AccountInfo<'info>,
|
|
||||||
signer: &AccountInfo<'info>,
|
|
||||||
system_program: &AccountInfo<'info>,
|
|
||||||
program_id: &Pubkey,
|
|
||||||
seeds: &[&[u8]],
|
|
||||||
data: Vec<u8>,
|
|
||||||
space: u64,
|
|
||||||
) -> Result<()> {
|
|
||||||
// ───────────────────────────────────────────────
|
|
||||||
// 1. Проверяем, создан ли аккаунт (если нет — owner = default)
|
|
||||||
if pda_account.owner == &Pubkey::default() {
|
|
||||||
msg!("Создаём PDA с размером {} байт", space);
|
|
||||||
|
|
||||||
let space = space; //+ 128; // Добавляется запас под метаданные
|
|
||||||
// Вычисляем необходимую арендную плату
|
|
||||||
let lamports = Rent::get()?.minimum_balance(space as usize);
|
|
||||||
|
|
||||||
// Формируем инструкцию
|
|
||||||
let create_instr = system_instruction::create_account(
|
|
||||||
signer.key,
|
|
||||||
pda_account.key,
|
|
||||||
lamports,
|
|
||||||
space,
|
|
||||||
program_id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Выполняем инструкцию с подписью от PDA
|
|
||||||
invoke_signed(
|
|
||||||
&create_instr,
|
|
||||||
&[signer.clone(), pda_account.clone(), system_program.clone()],
|
|
||||||
&[&seeds],
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────────────────────────────────
|
|
||||||
// 2. Пишем данные в аккаунт
|
|
||||||
let mut account_data = pda_account.try_borrow_mut_data()?;
|
|
||||||
|
|
||||||
let copy_len = std::cmp::min(account_data.len(), data.len());
|
|
||||||
account_data[..copy_len].copy_from_slice(&data[..copy_len]);
|
|
||||||
|
|
||||||
// Если хочешь дополнить оставшееся нулями — раскомментируй:
|
|
||||||
// for i in copy_len..account_data.len() {
|
|
||||||
// account_data[i] = 0;
|
|
||||||
// }
|
|
||||||
|
|
||||||
msg!("Успешно записано {} байт в PDA", copy_len);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Создаёт PDA аккаунт (если его ещё нет).
|
|
||||||
///
|
|
||||||
/// ⚠️ Если аккаунт уже существует, выбрасывается ошибка.
|
|
||||||
/// Используется внутри инструкций смарт-контракта.
|
|
||||||
///
|
|
||||||
/// Аргументы:
|
|
||||||
/// - `pda_account`: аккаунт, который хотим создать (PDA)
|
|
||||||
/// - `signer`: кто оплачивает создание аккаунта (обычно пользователь)
|
|
||||||
/// - `system_program`: системная программа (`111...111`)
|
|
||||||
/// - `program_id`: адрес текущей программы (используется для подписи PDA)
|
|
||||||
/// - `seeds`: массив сидов, по которым вычислялся PDA
|
|
||||||
/// - `space`: желаемый размер аккаунта в байтах (только данных, без метаданных)
|
|
||||||
pub fn create_pda<'info>(
|
|
||||||
pda_account: &AccountInfo<'info>,
|
|
||||||
signer: &AccountInfo<'info>,
|
|
||||||
system_program: &AccountInfo<'info>,
|
|
||||||
program_id: &Pubkey,
|
|
||||||
seeds: &[&[u8]],
|
|
||||||
space: u64,
|
|
||||||
) -> Result<()> {
|
|
||||||
// ───────────────────────────────────────────────
|
|
||||||
// 1. Проверяем, существует ли аккаунт
|
|
||||||
if pda_account.owner != &Pubkey::default() {
|
|
||||||
// Если владелец не равен Pubkey::default, значит аккаунт уже создан
|
|
||||||
// Возвращаем ошибку с пояснением
|
|
||||||
return Err(error!(ErrCode::PdaAlreadyExists));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────────────────────────────────
|
|
||||||
// 2. Логируем, что будем создавать PDA
|
|
||||||
msg!("Создаём PDA-аккаунт на {} байт", space);
|
|
||||||
|
|
||||||
// Добавляем запас под метаданные Solana (примерно 128 байт)
|
|
||||||
let full_space = space;
|
|
||||||
|
|
||||||
// Получаем минимальный баланс для аренды (чтобы аккаунт не удалили)
|
|
||||||
let lamports = Rent::get()?.minimum_balance(full_space as usize);
|
|
||||||
|
|
||||||
// ───────────────────────────────────────────────
|
|
||||||
// 3. Создаём инструкцию system_program для создания аккаунта
|
|
||||||
let create_instr = system_instruction::create_account(
|
|
||||||
signer.key, // от имени кого
|
|
||||||
pda_account.key, // для какого PDA
|
|
||||||
lamports, // сколько лампортов перевести
|
|
||||||
full_space, // сколько байт выделить
|
|
||||||
program_id, // кто будет владельцем PDA
|
|
||||||
);
|
|
||||||
|
|
||||||
// ───────────────────────────────────────────────
|
|
||||||
// 4. Выполняем инструкцию с подписью PDA (через сиды)
|
|
||||||
invoke_signed(
|
|
||||||
&create_instr,
|
|
||||||
&[signer.clone(), pda_account.clone(), system_program.clone()],
|
|
||||||
&[&seeds], // PDA сиды → для подписи
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Записывает массив байт в PDA аккаунт (в начало data-секции).
|
|
||||||
///
|
|
||||||
/// ⚠️ Убедись, что PDA был передан как `#[account(mut)]`
|
|
||||||
/// ⚠️ Эта функция ничего не создаёт, только пишет.
|
|
||||||
///
|
|
||||||
/// Аргументы:
|
|
||||||
/// - `pda_account`: аккаунт, в который пишем (должен быть mut)
|
|
||||||
/// - `data`: бинарный массив, который нужно записать
|
|
||||||
pub fn write_to_pda<'info>(pda_account: &AccountInfo<'info>, data: &[u8]) -> Result<()> {
|
|
||||||
// ───────────────────────────────────────────────
|
|
||||||
// 1. Получаем доступ к данным PDA (на запись)
|
|
||||||
let mut account_data = pda_account.try_borrow_mut_data()?;
|
|
||||||
|
|
||||||
// ───────────────────────────────────────────────
|
|
||||||
// 2. Вычисляем сколько байт реально можно записать
|
|
||||||
// (на случай, если data длиннее, чем выделено место)
|
|
||||||
let copy_len = std::cmp::min(account_data.len(), data.len());
|
|
||||||
|
|
||||||
// ───────────────────────────────────────────────
|
|
||||||
// 3. Копируем данные в аккаунт (с самого начала)
|
|
||||||
account_data[..copy_len].copy_from_slice(&data[..copy_len]);
|
|
||||||
|
|
||||||
// Логируем, сколько байт записано
|
|
||||||
msg!("Успешно записано {} байт в PDA", copy_len);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ------------------------------------------------------------------------
|
|
||||||
/// safe_read_pda ‒ «безопасное чтение PDA»
|
|
||||||
/// ------------------------------------------------------------------------
|
|
||||||
///
|
|
||||||
/// * Принимает: ссылку на `AccountInfo<'info>` PDA-аккаунта.
|
|
||||||
/// * Возвращает: `Vec<u8>` с данными аккаунта.
|
|
||||||
/// Если аккаунта нет или его данные пусты — возвращается `Vec::new()`
|
|
||||||
/// длиной 0 байт.
|
|
||||||
///
|
|
||||||
/// Как работает ───────────────────────────────────────────────────────────
|
|
||||||
/// 1. Проверяем, что аккаунт **инициализирован**: у не-инициализированного
|
|
||||||
/// owner = Pubkey::default(). Если owner нулевой — сразу отдаём пустой вектор.
|
|
||||||
/// 2. Если длина буфера == 0 (Anchor helper `data_is_empty()`), тоже отдаём пустой.
|
|
||||||
/// 3. Пытаемся безопасно (`try_borrow_data`) получить ссылку на данные.
|
|
||||||
/// - Успех → копируем их в Vec и возвращаем.
|
|
||||||
/// - Ошибка (например, конфликт borrow) → логируем и возвращаем пустой Vec.
|
|
||||||
///
|
|
||||||
/// пример использования
|
|
||||||
/// let raw_bytes = safe_read_pda(&ctx.accounts.readonly_pda);
|
|
||||||
/// require!(!raw_bytes.is_empty(), ErrCode::EmptyPdaData);
|
|
||||||
/// msg!("Размер считанных данных: {}", raw_bytes.len());
|
|
||||||
/// ------------------------------------------------------------------------
|
|
||||||
pub fn safe_read_pda<'info>(pda_account: &AccountInfo<'info>) -> Vec<u8> {
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
// 1) Аккаунт Н*Е* СУЩЕСТВУЕТ или не инициализирован:
|
|
||||||
// owner == Pubkey::default() (в Solana нулевой owner у пустого счёта)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
if pda_account.owner == &Pubkey::default() {
|
|
||||||
msg!("safe_read_pda: аккаунт не инициализирован ‒ возвращаем пустой массив");
|
|
||||||
return Vec::new(); // []
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
// 2) У аккаунта нет данных (длина 0) — тоже считаем «пустым»
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
if pda_account.data_is_empty() {
|
|
||||||
msg!("safe_read_pda: у аккаунта data_len == 0 ‒ возвращаем пустой массив");
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
// 3) Пытаемся безопасно забрать буфер данных; ошибки перехватываем
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
match pda_account.try_borrow_data() {
|
|
||||||
Ok(data_ref) => {
|
|
||||||
// to_vec() копирует bytes → Vec<u8>, чтобы дальше работать без borrow-лифа
|
|
||||||
data_ref.to_vec()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// Ошибка при borrow (например, уже есть активное мутабельное заимствование)
|
|
||||||
msg!(
|
|
||||||
"safe_read_pda: ошибка borrow_data ({:?}) ‒ возвращаем пустой массив",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ------------------------------------------------------------------------
|
|
||||||
/// delete_pda_with_assign — закрыть PDA, вернуть ренту и освободить адрес
|
|
||||||
/// ------------------------------------------------------------------------
|
|
||||||
///
|
|
||||||
/// Параметры:
|
|
||||||
/// - `pda_account` : PDA-аккаунт (mut), который закрываем (owned вашей программой)
|
|
||||||
/// - `recipient` : счёт, на который возвращаем лампорты (обычно пользователь)
|
|
||||||
/// - `system_program`: системная программа (111...111)
|
|
||||||
/// - `program_id` : Pubkey вашей программы (проверка владельца)
|
|
||||||
/// - `seeds` : сиды PDA (в том же порядке, как при создании), чтобы PDA «подписал» assign
|
|
||||||
///
|
|
||||||
/// Делает:
|
|
||||||
/// 1) Проверяет, что PDA принадлежит вашей программе.
|
|
||||||
/// 2) Обнуляет данные и сжимает их до 0 байт (realloc(0)).
|
|
||||||
/// 3) Переводит все лампорты PDA на `recipient`.
|
|
||||||
/// 4) Делает `assign` владельца на System Program (через `invoke_signed`).
|
|
||||||
///
|
|
||||||
/// Результат:
|
|
||||||
/// — В конце транзакции аккаунт с lamports=0 и data_len=0 будет удалён рантаймом,
|
|
||||||
/// владелец = System Program (чисто/ожидаемо).
|
|
||||||
/// — В следующей транзакции можно снова создать PDA с тем же сидом.
|
|
||||||
/// ------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub fn delete_pda_return_rent<'info>(
|
|
||||||
pda_account: &AccountInfo<'info>,
|
|
||||||
recipient: &AccountInfo<'info>,
|
|
||||||
program_id: &Pubkey,
|
|
||||||
) -> Result<()> {
|
|
||||||
// 0) проверки
|
|
||||||
require!(
|
|
||||||
pda_account.owner != &Pubkey::default(),
|
|
||||||
ErrCode::EmptyPdaData
|
|
||||||
);
|
|
||||||
require!(pda_account.owner == program_id, ErrCode::InvalidPdaAddress);
|
|
||||||
|
|
||||||
// 1) Переложить все лампорты с PDA на получателя (мы владелец, это разрешено)
|
|
||||||
let amount = **pda_account.lamports.borrow();
|
|
||||||
if amount > 0 {
|
|
||||||
**recipient.lamports.borrow_mut() = recipient
|
|
||||||
.lamports()
|
|
||||||
.checked_add(amount)
|
|
||||||
.ok_or(ProgramError::InsufficientFunds)?;
|
|
||||||
**pda_account.lamports.borrow_mut() = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Нулим данные (если были)
|
|
||||||
if !pda_account.data_is_empty() {
|
|
||||||
let mut data = pda_account.try_borrow_mut_data()?;
|
|
||||||
for b in data.iter_mut() {
|
|
||||||
*b = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Сжать до 0 байт
|
|
||||||
pda_account.realloc(0, false)?;
|
|
||||||
|
|
||||||
// Никаких assign/transfer больше не делаем — это надёжнее.
|
|
||||||
msg!("PDA закрыт: рента отправлена на {}", recipient.key);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@ -12,6 +12,8 @@ doctest = false
|
|||||||
bench = false
|
bench = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anchor-lang = "=0.31.1"
|
||||||
|
pyth-solana-receiver-sdk = "=0.6.0"
|
||||||
solana-program = "2.1.21"
|
solana-program = "2.1.21"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
use anchor_lang::AccountDeserialize;
|
||||||
|
use pyth_solana_receiver_sdk::error::GetPriceError;
|
||||||
|
use pyth_solana_receiver_sdk::price_update::{get_feed_id_from_hex, FeedId, PriceUpdateV2};
|
||||||
use solana_program::{
|
use solana_program::{
|
||||||
account_info::{next_account_info, AccountInfo},
|
account_info::{next_account_info, AccountInfo},
|
||||||
clock::Clock,
|
clock::Clock,
|
||||||
@ -596,7 +599,7 @@ fn process_init(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn process_update_coef_limit(
|
fn process_update_coef_limit(
|
||||||
_program_id: &Pubkey,
|
program_id: &Pubkey,
|
||||||
accounts: &[AccountInfo],
|
accounts: &[AccountInfo],
|
||||||
args: UpdateCoefLimitArgs,
|
args: UpdateCoefLimitArgs,
|
||||||
) -> ProgramResult {
|
) -> ProgramResult {
|
||||||
@ -606,6 +609,8 @@ fn process_update_coef_limit(
|
|||||||
let coef_limit_pda = next_account_info(account_iter)?;
|
let coef_limit_pda = next_account_info(account_iter)?;
|
||||||
require!(account_iter.next().is_none(), PaymentsError::InvalidInstruction);
|
require!(account_iter.next().is_none(), PaymentsError::InvalidInstruction);
|
||||||
|
|
||||||
|
validate_singleton_state_pda(program_id, config_pda, settings::CONFIG_SEED)?;
|
||||||
|
validate_singleton_state_pda(program_id, coef_limit_pda, settings::COEF_LIMIT_SEED)?;
|
||||||
let config = read_state::<ConfigState>(config_pda)?;
|
let config = read_state::<ConfigState>(config_pda)?;
|
||||||
require_keys_eq!(config.dao_wallet, *signer.key, PaymentsError::UnauthorizedDao);
|
require_keys_eq!(config.dao_wallet, *signer.key, PaymentsError::UnauthorizedDao);
|
||||||
require!(args.coef_ppm > 0, PaymentsError::InvalidCoefficient);
|
require!(args.coef_ppm > 0, PaymentsError::InvalidCoefficient);
|
||||||
@ -635,6 +640,7 @@ fn process_grant_manager_limits(
|
|||||||
require!(account_iter.next().is_none(), PaymentsError::InvalidInstruction);
|
require!(account_iter.next().is_none(), PaymentsError::InvalidInstruction);
|
||||||
require_system_program(system_program_ai)?;
|
require_system_program(system_program_ai)?;
|
||||||
|
|
||||||
|
validate_singleton_state_pda(program_id, config_pda, settings::CONFIG_SEED)?;
|
||||||
let config = read_state::<ConfigState>(config_pda)?;
|
let config = read_state::<ConfigState>(config_pda)?;
|
||||||
require_keys_eq!(config.dao_wallet, *signer.key, PaymentsError::UnauthorizedDao);
|
require_keys_eq!(config.dao_wallet, *signer.key, PaymentsError::UnauthorizedDao);
|
||||||
require!(
|
require!(
|
||||||
@ -738,6 +744,7 @@ fn process_manager_add_ticket(
|
|||||||
|
|
||||||
let (expected_manager_pda, _) = find_manager_allowance_pda(program_id, signer.key);
|
let (expected_manager_pda, _) = find_manager_allowance_pda(program_id, signer.key);
|
||||||
require_keys_eq!(expected_manager_pda, *manager_allowance_pda.key, PaymentsError::InvalidPdaAddress);
|
require_keys_eq!(expected_manager_pda, *manager_allowance_pda.key, PaymentsError::InvalidPdaAddress);
|
||||||
|
validate_singleton_state_pda(program_id, queues_pda, settings::QUEUES_SEED)?;
|
||||||
let mut allowance = read_state::<ManagerAllowanceState>(manager_allowance_pda)?;
|
let mut allowance = read_state::<ManagerAllowanceState>(manager_allowance_pda)?;
|
||||||
require_keys_eq!(allowance.manager_wallet, *signer.key, PaymentsError::InvalidManagerWallet);
|
require_keys_eq!(allowance.manager_wallet, *signer.key, PaymentsError::InvalidManagerWallet);
|
||||||
|
|
||||||
@ -793,6 +800,10 @@ fn process_step_payout(program_id: &Pubkey, accounts: &[AccountInfo]) -> Program
|
|||||||
let sol_usd_price_update = next_account_info(account_iter)?;
|
let sol_usd_price_update = next_account_info(account_iter)?;
|
||||||
require!(account_iter.next().is_none(), PaymentsError::InvalidInstruction);
|
require!(account_iter.next().is_none(), PaymentsError::InvalidInstruction);
|
||||||
|
|
||||||
|
validate_singleton_state_pda(program_id, config_pda, settings::CONFIG_SEED)?;
|
||||||
|
validate_singleton_state_pda(program_id, coef_limit_pda, settings::COEF_LIMIT_SEED)?;
|
||||||
|
validate_singleton_state_pda(program_id, queues_pda, settings::QUEUES_SEED)?;
|
||||||
|
validate_singleton_state_pda(program_id, inflow_vault_pda, settings::INFLOW_VAULT_SEED)?;
|
||||||
let config = read_state::<ConfigState>(config_pda)?;
|
let config = read_state::<ConfigState>(config_pda)?;
|
||||||
let coef_limit = read_state::<CoefLimitState>(coef_limit_pda)?;
|
let coef_limit = read_state::<CoefLimitState>(coef_limit_pda)?;
|
||||||
let mut queues = read_state::<QueuesState>(queues_pda)?;
|
let mut queues = read_state::<QueuesState>(queues_pda)?;
|
||||||
@ -856,6 +867,7 @@ fn process_change_ticket_recipient(
|
|||||||
let ticket_pda = next_account_info(account_iter)?;
|
let ticket_pda = next_account_info(account_iter)?;
|
||||||
require!(account_iter.next().is_none(), PaymentsError::InvalidInstruction);
|
require!(account_iter.next().is_none(), PaymentsError::InvalidInstruction);
|
||||||
|
|
||||||
|
validate_singleton_state_pda(program_id, queues_pda, settings::QUEUES_SEED)?;
|
||||||
let queues = read_state::<QueuesState>(queues_pda)?;
|
let queues = read_state::<QueuesState>(queues_pda)?;
|
||||||
let mut ticket = read_state::<TicketState>(ticket_pda)?;
|
let mut ticket = read_state::<TicketState>(ticket_pda)?;
|
||||||
require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid);
|
require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid);
|
||||||
@ -944,6 +956,17 @@ fn ensure_expected_pdas(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_singleton_state_pda(
|
||||||
|
program_id: &Pubkey,
|
||||||
|
pda: &AccountInfo,
|
||||||
|
seed: &[u8],
|
||||||
|
) -> ProgramResult {
|
||||||
|
let (expected_pda, _) = find_single_pda(program_id, seed);
|
||||||
|
require_keys_eq!(expected_pda, *pda.key, PaymentsError::InvalidPdaAddress);
|
||||||
|
require_keys_eq!(*pda.owner, id(), PaymentsError::InvalidPdaAddress);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn find_single_pda(program_id: &Pubkey, seed: &[u8]) -> (Pubkey, u8) {
|
fn find_single_pda(program_id: &Pubkey, seed: &[u8]) -> (Pubkey, u8) {
|
||||||
Pubkey::find_program_address(&[seed], program_id)
|
Pubkey::find_program_address(&[seed], program_id)
|
||||||
}
|
}
|
||||||
@ -979,6 +1002,9 @@ fn buy_ticket_by_purchase_usd(
|
|||||||
transfer_lamports: u64,
|
transfer_lamports: u64,
|
||||||
recipient_wallet: Pubkey,
|
recipient_wallet: Pubkey,
|
||||||
) -> ProgramResult {
|
) -> ProgramResult {
|
||||||
|
validate_singleton_state_pda(program_id, ctx.config_pda, settings::CONFIG_SEED)?;
|
||||||
|
validate_singleton_state_pda(program_id, ctx.coef_limit_pda, settings::COEF_LIMIT_SEED)?;
|
||||||
|
validate_singleton_state_pda(program_id, ctx.queues_pda, settings::QUEUES_SEED)?;
|
||||||
let config = read_state::<ConfigState>(ctx.config_pda)?;
|
let config = read_state::<ConfigState>(ctx.config_pda)?;
|
||||||
let coef_limit = read_state::<CoefLimitState>(ctx.coef_limit_pda)?;
|
let coef_limit = read_state::<CoefLimitState>(ctx.coef_limit_pda)?;
|
||||||
let mut queues = read_state::<QueuesState>(ctx.queues_pda)?;
|
let mut queues = read_state::<QueuesState>(ctx.queues_pda)?;
|
||||||
@ -1039,21 +1065,50 @@ fn read_sol_usd_price(price_update: &AccountInfo, key: &Pubkey) -> Result<SolUsd
|
|||||||
let expected_oracle = Pubkey::from_str(settings::PYTH_SOL_USD_ACCOUNT)
|
let expected_oracle = Pubkey::from_str(settings::PYTH_SOL_USD_ACCOUNT)
|
||||||
.map_err(|_| ProgramError::from(PaymentsError::InvalidOracleFeedConfig))?;
|
.map_err(|_| ProgramError::from(PaymentsError::InvalidOracleFeedConfig))?;
|
||||||
require_keys_eq!(expected_oracle, *key, PaymentsError::InvalidOracleAccount);
|
require_keys_eq!(expected_oracle, *key, PaymentsError::InvalidOracleAccount);
|
||||||
|
require_keys_eq!(*price_update.owner, pyth_solana_receiver_sdk::ID, PaymentsError::InvalidOracleAccount);
|
||||||
|
|
||||||
let data = price_update.try_borrow_data()?;
|
let data = price_update.try_borrow_data()?;
|
||||||
let clock = Clock::get()?;
|
let clock = Clock::get()?;
|
||||||
parse_pyth_price_update_v2(&data, &clock)
|
let mut data_slice: &[u8] = &data;
|
||||||
|
let price_update_state = PriceUpdateV2::try_deserialize(&mut data_slice)
|
||||||
|
.map_err(|_| ProgramError::from(PaymentsError::InvalidOraclePrice))?;
|
||||||
|
let feed_id = expected_sol_usd_feed_id()?;
|
||||||
|
let price = price_update_state
|
||||||
|
.get_price_no_older_than(&clock, settings::ORACLE_MAX_AGE_SECS, &feed_id)
|
||||||
|
.map_err(map_pyth_price_error)?;
|
||||||
|
require!(price.price > 0, PaymentsError::InvalidOraclePrice);
|
||||||
|
require_oracle_confidence_ok(price.price, price.conf)?;
|
||||||
|
sol_usd_price_from_components(price.price, price.exponent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_pyth_price_update_v2(data: &[u8], clock: &Clock) -> Result<SolUsdPrice, ProgramError> {
|
fn expected_sol_usd_feed_id() -> Result<FeedId, ProgramError> {
|
||||||
require!(data.len() > 100, PaymentsError::InvalidOraclePrice);
|
get_feed_id_from_hex(settings::PYTH_SOL_USD_FEED_ID)
|
||||||
let price = read_i64_at(data, 73)?;
|
.map_err(|_| ProgramError::from(PaymentsError::InvalidOracleFeedConfig))
|
||||||
let exponent = read_i32_at(data, 89)?;
|
}
|
||||||
let publish_time = read_i64_at(data, 93)?;
|
|
||||||
|
fn map_pyth_price_error(err: GetPriceError) -> ProgramError {
|
||||||
|
match err {
|
||||||
|
GetPriceError::PriceTooOld => PaymentsError::OraclePriceTooOld.into(),
|
||||||
|
GetPriceError::MismatchedFeedId => PaymentsError::InvalidOracleFeed.into(),
|
||||||
|
GetPriceError::InsufficientVerificationLevel => PaymentsError::InvalidOraclePrice.into(),
|
||||||
|
GetPriceError::FeedIdMustBe32Bytes | GetPriceError::FeedIdNonHexCharacter => {
|
||||||
|
PaymentsError::InvalidOracleFeedConfig.into()
|
||||||
|
}
|
||||||
|
GetPriceError::InvalidWindowSize => PaymentsError::InvalidOraclePrice.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_oracle_confidence_ok(price: i64, conf: u64) -> ProgramResult {
|
||||||
|
require!(price > 0, PaymentsError::InvalidOraclePrice);
|
||||||
|
let conf_ppm = checked_mul_u128(conf as u128, 1_000_000u128)? / (price as u128);
|
||||||
require!(
|
require!(
|
||||||
publish_time.saturating_add(settings::ORACLE_MAX_AGE_SECS as i64) >= clock.unix_timestamp,
|
conf_ppm <= settings::ORACLE_MAX_CONFIDENCE_PPM as u128,
|
||||||
PaymentsError::OraclePriceTooOld
|
PaymentsError::InvalidOraclePrice
|
||||||
);
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sol_usd_price_from_components(price: i64, exponent: i32) -> Result<SolUsdPrice, ProgramError> {
|
||||||
require!(price > 0, PaymentsError::InvalidOraclePrice);
|
require!(price > 0, PaymentsError::InvalidOraclePrice);
|
||||||
let mut num = checked_mul_u128(price as u128, settings::USD_CENTS_SCALE as u128)?;
|
let mut num = checked_mul_u128(price as u128, settings::USD_CENTS_SCALE as u128)?;
|
||||||
let mut den: u128 = 1;
|
let mut den: u128 = 1;
|
||||||
@ -1074,34 +1129,6 @@ fn parse_pyth_price_update_v2(data: &[u8], clock: &Clock) -> Result<SolUsdPrice,
|
|||||||
Ok(SolUsdPrice { price_num: num, price_den: den })
|
Ok(SolUsdPrice { price_num: num, price_den: den })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_i32_at(data: &[u8], offset: usize) -> Result<i32, ProgramError> {
|
|
||||||
let end = offset
|
|
||||||
.checked_add(4)
|
|
||||||
.ok_or(ProgramError::from(PaymentsError::InvalidOraclePrice))?;
|
|
||||||
let slice = data
|
|
||||||
.get(offset..end)
|
|
||||||
.ok_or(ProgramError::from(PaymentsError::InvalidOraclePrice))?;
|
|
||||||
Ok(i32::from_le_bytes(
|
|
||||||
slice
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| ProgramError::from(PaymentsError::InvalidOraclePrice))?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_i64_at(data: &[u8], offset: usize) -> Result<i64, ProgramError> {
|
|
||||||
let end = offset
|
|
||||||
.checked_add(8)
|
|
||||||
.ok_or(ProgramError::from(PaymentsError::InvalidOraclePrice))?;
|
|
||||||
let slice = data
|
|
||||||
.get(offset..end)
|
|
||||||
.ok_or(ProgramError::from(PaymentsError::InvalidOraclePrice))?;
|
|
||||||
Ok(i64::from_le_bytes(
|
|
||||||
slice
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| ProgramError::from(PaymentsError::InvalidOraclePrice))?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lamports_to_usd_cents_floor(lamports: u64, price: &SolUsdPrice) -> Result<u64, ProgramError> {
|
fn lamports_to_usd_cents_floor(lamports: u64, price: &SolUsdPrice) -> Result<u64, ProgramError> {
|
||||||
let numerator = checked_mul_u128(lamports as u128, price.price_num)?;
|
let numerator = checked_mul_u128(lamports as u128, price.price_num)?;
|
||||||
let denominator = checked_mul_u128(settings::LAMPORTS_PER_SOL as u128, price.price_den)?;
|
let denominator = checked_mul_u128(settings::LAMPORTS_PER_SOL as u128, price.price_den)?;
|
||||||
|
|||||||
@ -45,6 +45,9 @@ pub const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
|
|||||||
|
|
||||||
/// `ORACLE_MAX_AGE_SECS` — максимальный возраст oracle-цены (в секундах), допустимый для расчетов.
|
/// `ORACLE_MAX_AGE_SECS` — максимальный возраст oracle-цены (в секундах), допустимый для расчетов.
|
||||||
pub const ORACLE_MAX_AGE_SECS: u64 = 120;
|
pub const ORACLE_MAX_AGE_SECS: u64 = 120;
|
||||||
|
/// `ORACLE_MAX_CONFIDENCE_PPM` — максимальная относительная ширина доверительного интервала oracle-цены.
|
||||||
|
/// Если `conf / price` выше этого порога, цена считается слишком неопределённой для покупки и payout.
|
||||||
|
pub const ORACLE_MAX_CONFIDENCE_PPM: u64 = 100_000; // 10%
|
||||||
/// `PYTH_SOL_USD_FEED_ID` — feed id Pyth для пары SOL/USD.
|
/// `PYTH_SOL_USD_FEED_ID` — feed id Pyth для пары SOL/USD.
|
||||||
pub const PYTH_SOL_USD_FEED_ID: &str =
|
pub const PYTH_SOL_USD_FEED_ID: &str =
|
||||||
"0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d";
|
"0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d";
|
||||||
|
|||||||
@ -444,6 +444,7 @@ fn process_create_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args:
|
|||||||
require!(args.additional_limit % settings::LIMIT_STEP == 0, ShineUsersError::InvalidLimitIncrement);
|
require!(args.additional_limit % settings::LIMIT_STEP == 0, ShineUsersError::InvalidLimitIncrement);
|
||||||
require_keys_eq!(*login_guard_program.key, Pubkey::from_str(settings::SHINE_LOGIN_GUARD_PROGRAM_ID).map_err(|_| ProgramError::from(ShineUsersError::InvalidLoginGuardResponse))?, ShineUsersError::InvalidLoginGuardResponse);
|
require_keys_eq!(*login_guard_program.key, Pubkey::from_str(settings::SHINE_LOGIN_GUARD_PROGRAM_ID).map_err(|_| ProgramError::from(ShineUsersError::InvalidLoginGuardResponse))?, ShineUsersError::InvalidLoginGuardResponse);
|
||||||
classify_login_or_fail(login_guard_program, &args.login)?;
|
classify_login_or_fail(login_guard_program, &args.login)?;
|
||||||
|
validate_users_economy_config_pda(program_id, users_economy_config_pda)?;
|
||||||
|
|
||||||
let economy = read_users_economy_config(users_economy_config_pda)?;
|
let economy = read_users_economy_config(users_economy_config_pda)?;
|
||||||
let login_seed = login_seed_normalized(&args.login);
|
let login_seed = login_seed_normalized(&args.login);
|
||||||
@ -522,6 +523,7 @@ fn process_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args:
|
|||||||
validate_fields(&args.fields)?;
|
validate_fields(&args.fields)?;
|
||||||
validate_inflow_vault(inflow_vault)?;
|
validate_inflow_vault(inflow_vault)?;
|
||||||
require!(args.additional_limit % settings::LIMIT_STEP == 0, ShineUsersError::InvalidLimitIncrement);
|
require!(args.additional_limit % settings::LIMIT_STEP == 0, ShineUsersError::InvalidLimitIncrement);
|
||||||
|
validate_users_economy_config_pda(program_id, users_economy_config_pda)?;
|
||||||
let economy = read_users_economy_config(users_economy_config_pda)?;
|
let economy = read_users_economy_config(users_economy_config_pda)?;
|
||||||
|
|
||||||
let normalized_login = login_seed_normalized(&args.login);
|
let normalized_login = login_seed_normalized(&args.login);
|
||||||
@ -630,6 +632,13 @@ fn serialize_users_economy_config(state: &UsersEconomyConfigState) -> Vec<u8> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_users_economy_config_pda(program_id: &Pubkey, pda: &AccountInfo) -> ProgramResult {
|
||||||
|
let (expected_pda, _) = find_users_economy_config_pda(program_id);
|
||||||
|
require_keys_eq!(expected_pda, *pda.key, ShineUsersError::InvalidPdaAddress);
|
||||||
|
require!(pda.owner == program_id, ShineUsersError::InvalidPdaAddress);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn read_users_economy_config(pda: &AccountInfo) -> Result<UsersEconomyConfigState, ProgramError> {
|
fn read_users_economy_config(pda: &AccountInfo) -> Result<UsersEconomyConfigState, ProgramError> {
|
||||||
let raw = read_pda_all(pda)?;
|
let raw = read_pda_all(pda)?;
|
||||||
require!(!raw.is_empty(), ShineUsersError::EmptyPdaData);
|
require!(!raw.is_empty(), ShineUsersError::EmptyPdaData);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user