Новый shine_payments v2, legacy-перенос и обновление Devnet UI

This commit is contained in:
AidarKC 2026-05-05 18:27:42 +03:00
parent 09dea46948
commit 0a9b76055f
23 changed files with 2125 additions and 941 deletions

View File

@ -7,3 +7,19 @@
- `doc/SHINE_USER_PDA_V1.md` - `doc/SHINE_USER_PDA_V1.md`
Если меняется формат записи, сериализация, правила подписи, `prev_hash`, экономика лимитов или связанные ограничения create/update, этот документ нужно обновлять в том же изменении. Если меняется формат записи, сериализация, правила подписи, `prev_hash`, экономика лимитов или связанные ограничения create/update, этот документ нужно обновлять в том же изменении.
## Language Rule
Во всем проекте использовать русский язык:
- комментарии в коде;
- тексты в файлах настроек и справочных файлах;
- сообщения и описания в коммитах;
- сопроводительные технические заметки.
## Rule: Logic and Docs
Если меняется бизнес-логика смарт-контрактов, сериализация PDA, правила переводов или экономика:
1. Обновить соответствующий документ в `doc/` в том же изменении.
2. Если документ сразу обновить нельзя, обязательно явно согласовать это с пользователем в чате и зафиксировать план обновления.

View File

@ -5,35 +5,23 @@ package_manager = "yarn"
resolution = true resolution = true
skip-lint = false skip-lint = false
[programs.localnet]
shine_users = "5dFcWDNp42Xn9Vv4oDMJzM4obBJ8hvDuAtPX54fT5L3t" #тут надо если что обновлять
shine_payments = "92sgkgx7KHpbhQu81mNGHaKa7skJB7esArVdPM7paDSW" #тут надо если что обновлять
[programs.devnet] [programs.devnet]
shine_users = "5dFcWDNp42Xn9Vv4oDMJzM4obBJ8hvDuAtPX54fT5L3t" #тут надо если что обновлять shine_payments = "4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE"
shine_payments = "92sgkgx7KHpbhQu81mNGHaKa7skJB7esArVdPM7paDSW" #тут надо если что обновлять shine_users = "8Z3HQizFRhyVu5cNBwWNBXZHTpu89VMkn7Wuk1oCtkeJ"
[programs.localnet]
[workspace] shine_payments = "4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE"
members = [ shine_users = "5dFcWDNp42Xn9Vv4oDMJzM4obBJ8hvDuAtPX54fT5L3t"
"programs/shine_users",
"programs/shine_payments",
]
[registry] [registry]
url = "https://api.apr.dev" url = "https://api.apr.dev"
[provider] [provider]
cluster = "devnet"#"http://127.0.0.1:8899" # это в какую сеть деплоит по умолчанию cluster = "devnet"
wallet = "~/.config/solana/id.json" # а это с какого кошелько спишутся средства за деплой wallet = "~/.config/solana/id.json"
[workspace]
members = ["programs/shine_users", "programs/shine_payments"]
[scripts] [scripts]
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"

769
shine/Cargo.lock generated
View File

@ -2,42 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm-siv"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"polyval",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@ -59,12 +23,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "anchor-attribute-access-control" name = "anchor-attribute-access-control"
version = "0.31.1" version = "0.31.1"
@ -225,21 +183,6 @@ dependencies = [
"serde", "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 7.0.0",
"spl-token-2022",
"spl-token-group-interface",
"spl-token-metadata-interface",
]
[[package]] [[package]]
name = "anchor-syn" name = "anchor-syn"
version = "0.31.1" version = "0.31.1"
@ -446,9 +389,6 @@ 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"
@ -461,12 +401,6 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "cargo_toml" name = "cargo_toml"
version = "0.19.2" version = "0.19.2"
@ -498,16 +432,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 = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]] [[package]]
name = "common" name = "common"
version = "0.1.0" version = "0.1.0"
@ -563,32 +487,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61"
dependencies = [
"byteorder",
"digest 0.9.0",
"rand_core 0.5.1",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "curve25519-dalek" name = "curve25519-dalek"
version = "4.1.3" version = "4.1.3"
@ -602,7 +503,6 @@ dependencies = [
"fiat-crypto", "fiat-crypto",
"rand_core 0.6.4", "rand_core 0.6.4",
"rustc_version", "rustc_version",
"serde",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@ -618,12 +518,6 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "derivation-path"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e5c37193a1db1d8ed868c03ec7b152175f26160a5b740e5e484143877e0adf0"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.9.0" version = "0.9.0"
@ -644,33 +538,6 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "ed25519"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7"
dependencies = [
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d"
dependencies = [
"curve25519-dalek 3.2.0",
"ed25519",
"sha2 0.9.9",
"zeroize",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -713,18 +580,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2551bf44bc5f776c15044b9b94153a00198be06743e262afaaa61f11ac7523a5" checksum = "2551bf44bc5f776c15044b9b94153a00198be06743e262afaaa61f11ac7523a5"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@ -773,11 +628,6 @@ name = "hashbrown"
version = "0.15.2" version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]] [[package]]
name = "heck" name = "heck"
@ -788,15 +638,6 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.9.0" version = "2.9.0"
@ -807,24 +648,6 @@ dependencies = [
"hashbrown 0.15.2", "hashbrown 0.15.2",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "itertools"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@ -939,31 +762,6 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "merlin"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d"
dependencies = [
"byteorder",
"keccak",
"rand_core 0.6.4",
"zeroize",
]
[[package]]
name = "mpl-token-metadata"
version = "5.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046f0779684ec348e2759661361c8798d79021707b1392cb49f3b5eb911340ff"
dependencies = [
"borsh 0.10.4",
"num-derive 0.3.3",
"num-traits",
"solana-program",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.4.6"
@ -974,17 +772,6 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-derive"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "num-derive" name = "num-derive"
version = "0.4.2" version = "0.4.2"
@ -1014,28 +801,6 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "num_enum"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 3.3.0",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@ -1071,33 +836,6 @@ dependencies = [
"windows-targets", "windows-targets",
] ]
[[package]]
name = "pbkdf2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@ -1134,15 +872,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "qstring"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d464fae65fff2680baf48019211ce37aaec0c78e9264c84a3e484717f965104e"
dependencies = [
"percent-encoding",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.40"
@ -1380,13 +1109,10 @@ dependencies = [
[[package]] [[package]]
name = "shine_payments" name = "shine_payments"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anchor-lang", "anchor-lang",
"anchor-spl",
"common", "common",
"mpl-token-metadata",
"spl-token 4.0.2",
] ]
[[package]] [[package]]
@ -1395,8 +1121,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anchor-lang", "anchor-lang",
"common", "common",
"ed25519-dalek",
"sha2 0.10.9",
] ]
[[package]] [[package]]
@ -1405,12 +1129,6 @@ 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 = "signature"
version = "1.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.0" version = "1.15.0"
@ -1540,20 +1258,6 @@ dependencies = [
"solana-stable-layout", "solana-stable-layout",
] ]
[[package]]
name = "solana-curve25519"
version = "2.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eae4261b9a8613d10e77ac831a8fa60b6fa52b9b103df46d641deff9f9812a23"
dependencies = [
"bytemuck",
"bytemuck_derive",
"curve25519-dalek 4.1.3",
"solana-define-syscall",
"subtle",
"thiserror 2.0.12",
]
[[package]] [[package]]
name = "solana-decode-error" name = "solana-decode-error"
version = "2.3.0" version = "2.3.0"
@ -1569,17 +1273,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ae3e2abcf541c8122eafe9a625d4d194b4023c20adde1e251f94e056bb1aee2" checksum = "2ae3e2abcf541c8122eafe9a625d4d194b4023c20adde1e251f94e056bb1aee2"
[[package]]
name = "solana-derivation-path"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "939756d798b25c5ec3cca10e06212bdca3b1443cb9bb740a38124f58b258737b"
dependencies = [
"derivation-path",
"qstring",
"uriparse",
]
[[package]] [[package]]
name = "solana-epoch-rewards" name = "solana-epoch-rewards"
version = "2.2.1" version = "2.2.1"
@ -1851,7 +1544,7 @@ dependencies = [
"log", "log",
"memoffset", "memoffset",
"num-bigint", "num-bigint",
"num-derive 0.4.2", "num-derive",
"num-traits", "num-traits",
"rand 0.8.5", "rand 0.8.5",
"serde", "serde",
@ -1975,7 +1668,7 @@ dependencies = [
"borsh 1.5.7", "borsh 1.5.7",
"bytemuck", "bytemuck",
"bytemuck_derive", "bytemuck_derive",
"curve25519-dalek 4.1.3", "curve25519-dalek",
"five8", "five8",
"five8_const", "five8_const",
"getrandom 0.2.16", "getrandom 0.2.16",
@ -2042,35 +1735,6 @@ dependencies = [
"thiserror 2.0.12", "thiserror 2.0.12",
] ]
[[package]]
name = "solana-security-txt"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "156bb61a96c605fa124e052d630dba2f6fb57e08c7d15b757e1e958b3ed7b3fe"
dependencies = [
"hashbrown 0.15.2",
]
[[package]]
name = "solana-seed-derivable"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beb82b5adb266c6ea90e5cf3967235644848eac476c5a1f2f9283a143b7c97f"
dependencies = [
"solana-derivation-path",
]
[[package]]
name = "solana-seed-phrase"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36187af2324f079f65a675ec22b31c24919cb4ac22c79472e85d819db9bbbc15"
dependencies = [
"hmac",
"pbkdf2",
"sha2 0.10.9",
]
[[package]] [[package]]
name = "solana-serde-varint" name = "solana-serde-varint"
version = "2.2.2" version = "2.2.2"
@ -2111,27 +1775,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "solana-signature"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64c8ec8e657aecfc187522fc67495142c12f35e55ddeca8698edbb738b8dbd8c"
dependencies = [
"five8",
"solana-sanitize",
]
[[package]]
name = "solana-signer"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c41991508a4b02f021c1342ba00bcfa098630b213726ceadc7cb032e051975b"
dependencies = [
"solana-pubkey",
"solana-signature",
"solana-transaction-error",
]
[[package]] [[package]]
name = "solana-slot-hashes" name = "solana-slot-hashes"
version = "2.2.1" version = "2.2.1"
@ -2269,7 +1912,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef4f08746f154458f28b98330c0d55cb431e2de64ee4b8efc98dcbe292e0672b" checksum = "ef4f08746f154458f28b98330c0d55cb431e2de64ee4b8efc98dcbe292e0672b"
dependencies = [ dependencies = [
"bincode", "bincode",
"num-derive 0.4.2", "num-derive",
"num-traits", "num-traits",
"serde", "serde",
"serde_derive", "serde_derive",
@ -2286,376 +1929,6 @@ dependencies = [
"solana-system-interface", "solana-system-interface",
] ]
[[package]]
name = "solana-zk-sdk"
version = "2.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b9fc6ec37d16d0dccff708ed1dd6ea9ba61796700c3bb7c3b401973f10f63b"
dependencies = [
"aes-gcm-siv",
"base64 0.22.1",
"bincode",
"bytemuck",
"bytemuck_derive",
"curve25519-dalek 4.1.3",
"itertools",
"js-sys",
"merlin",
"num-derive 0.4.2",
"num-traits",
"rand 0.8.5",
"serde",
"serde_derive",
"serde_json",
"sha3",
"solana-derivation-path",
"solana-instruction",
"solana-pubkey",
"solana-sdk-ids",
"solana-seed-derivable",
"solana-seed-phrase",
"solana-signature",
"solana-signer",
"subtle",
"thiserror 2.0.12",
"wasm-bindgen",
"zeroize",
]
[[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.5.7",
"num-derive 0.4.2",
"num-traits",
"solana-program",
"spl-associated-token-account-client",
"spl-token 7.0.0",
"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.101",
]
[[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.101",
"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-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.5.7",
"bytemuck",
"bytemuck_derive",
"num-derive 0.4.2",
"num-traits",
"solana-decode-error",
"solana-msg",
"solana-program-error",
"solana-program-option",
"solana-pubkey",
"solana-zk-sdk",
"thiserror 2.0.12",
]
[[package]]
name = "spl-program-error"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d39b5186f42b2b50168029d81e58e800b690877ef0b30580d107659250da1d1"
dependencies = [
"num-derive 0.4.2",
"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.101",
]
[[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 0.4.2",
"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 = "4.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e9e171cbcb4b1f72f6d78ed1e975cb467f56825c27d09b8dd2608e4e7fc8b3b"
dependencies = [
"arrayref",
"bytemuck",
"num-derive 0.4.2",
"num-traits",
"num_enum",
"solana-program",
"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 0.4.2",
"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 0.4.2",
"num-traits",
"num_enum",
"solana-program",
"solana-security-txt",
"solana-zk-sdk",
"spl-elgamal-registry",
"spl-memo",
"spl-pod",
"spl-token 7.0.0",
"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.12",
]
[[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 0.4.2",
"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.5.7",
"num-derive 0.4.2",
"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 0.4.2",
"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 0.4.2",
"num-traits",
"solana-account-info",
"solana-decode-error",
"solana-msg",
"solana-program-error",
"spl-discriminator",
"spl-pod",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -2807,26 +2080,6 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "uriparse"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff"
dependencies = [
"fnv",
"lazy_static",
]
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@ -3011,17 +2264,3 @@ name = "zeroize"
version = "1.8.1" version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]

View File

@ -0,0 +1,73 @@
# SHINE Payments v2 (краткое описание)
## Назначение
`shine_payments` v2 — контракт очереди выплат:
1. Покупка билета в очередь 1:
- пользователь переводит SOL в DAO;
- получает тикет с суммой будущей выплаты `input * coef`.
2. Шаг выплат:
- из inflow-вольта платится следующий тикет;
- такая же сумма отправляется в DAO;
- фиксированная награда отправляется вызвавшему шаг.
## PDA
1. `config_pda` (`shine_payments_v2_config`)
- `dao_wallet`
- `manager_wallet`
- `inflow_vault`
- `call_reward_lamports`
2. `coef_limit_pda` (`shine_payments_v2_coef_limit`)
- `coef_ppm` (коэффициент в fixed-point, scale = 1_000_000)
- `limit_lamports`
3. `queues_pda` (`shine_payments_v2_queues`)
- очередь 1: `tickets_total`, `tickets_paid`, `sum_total`, `sum_paid`
- очередь 2: `tickets_total`, `tickets_paid`, `sum_total`, `sum_paid`
4. `inflow_vault_pda` (`shine_payments_v2_inflow_vault`)
- PDA-вольт программы для выплат.
5. `q1_ticket_pda` (`shine_payments_v2_q1_ticket + index_le_u64`)
- `is_paid`
- `recipient_wallet`
- `payout_lamports`
- `debt_before_lamports`
- `index`
## Методы
1. `init`
- вызывается один раз (кто угодно);
- создает `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda`.
2. `update_coef_limit` (только manager)
- меняет коэффициент и лимит.
3. `buy_ticket`
- проверяет, что текущий долг очереди 1 меньше лимита;
- переводит входную сумму в DAO;
- создает тикет в очереди 1;
- увеличивает агрегаты очереди.
4. `step_payout`
- если есть тикеты в очереди 1:
- платит `X` получателю тикета,
- платит `X` в DAO,
- платит reward вызвавшему;
- помечает тикет выплаченным, обновляет агрегаты.
- если обе очереди пусты/полностью выплачены:
- переводит из inflow-вольта в DAO весь доступный остаток (сверх ренты),
- reward не платится.
## Стартовые настройки
См. `programs/shine_payments/src/settings.rs`:
- `START_COEF_PPM = 5_000_000` (коэффициент 5.0)
- `START_LIMIT_LAMPORTS = 100 SOL`
- `START_CALL_REWARD_LAMPORTS = 0.008 SOL`
- `DAO_WALLET`, `MANAGER_WALLET`

View File

@ -71,6 +71,8 @@
3. `signature = Ed25519.sign(root_private_key, msg_hash)`. 3. `signature = Ed25519.sign(root_private_key, msg_hash)`.
4. Проверка: 4. Проверка:
- `Ed25519.verify(root_key, msg_hash, signature)`. - `Ed25519.verify(root_key, msg_hash, signature)`.
- В текущей реализации проверка выполняется через встроенную Solana-инструкцию `Ed25519Program`
(инструкция должна идти в транзакции перед вызовом `create_user_pda` / `update_user_pda`).
## 5. Что входит в `prev_hash` ## 5. Что входит в `prev_hash`

View File

@ -1,7 +1,7 @@
[package] [package]
name = "shine_payments" name = "shine_payments"
version = "0.1.0" version = "0.2.0"
description = "Payments and investments smart contract" description = "Shine Payments v2 (очереди выплат)"
edition = "2021" edition = "2021"
[lib] [lib]
@ -15,12 +15,6 @@ bench = false
anchor-lang = "0.31.1" anchor-lang = "0.31.1"
common = { path = "../common" } common = { path = "../common" }
# ==== добавлено для NFT-функционала ====
anchor-spl = { version = "0.31.1", features = ["associated_token", "token"] }
mpl-token-metadata = "5.1.1"
spl-token = { version = "4.0.0", features = ["no-entrypoint"] }
# ======================================
[features] [features]
default = [] default = []
no-entrypoint = [] no-entrypoint = []

View File

@ -1,158 +1,605 @@
use anchor_lang::prelude::*; use anchor_lang::prelude::*;
use anchor_lang::solana_program::{program::invoke, system_instruction};
use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode};
use std::str::FromStr;
declare_id!("6Hes38UKFGF8cfQDQDVWoMGcSzGMUAgamWG31hCVhyPY"); pub mod settings;
declare_id!("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE");
/// Подключаем модуль с полной реализацией.
pub mod investments;
use investments::*; // импортируем всё в корень
// === модуль NFT ===
pub mod nft;
// ==============================================
// Константы формата / сидов / размеров
// ==============================================
/// Префикс (seed) для PDA, где храним глобальное состояние выплат.
/// Важно: сид — это просто набор байт; здесь он фиксированный.
pub const PDA_SEED_PREFIX: &[u8] = b"shine_investments_state";
/// Значение коэффициента «по умолчанию» при инициализации.
pub const DEFAULT_COEF: u32 = 10; // ← «коэффициент» = 10 при init
/// Ровно столько байт резервируем под PDA-данные.
/// (Можно добавить запас на будущее, но по заданию — только 28.)
pub const PAY_STATE_SPACE: u64 = 50; // просто сделал с запасом
// ==============================================
// Программа
// ==============================================
#[program] #[program]
pub mod shine_payments { pub mod shine_payments {
use super::*; use super::*;
// Явно подтягиваем типы и функции, чтобы не было путаницы после предыдущих ошибок парсера
use crate::investments::{Init, UseState};
use crate::investments::{
add_bonus as inv_add_bonus, claim as inv_claim, init as inv_init, invest as inv_invest,
ErrCode,
};
/// init — создаёт PDA и кладёт дефолтное состояние.
pub fn init(ctx: Context<Init>) -> Result<()> { pub fn init(ctx: Context<Init>) -> Result<()> {
inv_init(ctx) ensure_expected_pdas(ctx.program_id, &ctx.accounts)?;
require!(
ctx.accounts.config_pda.owner == &Pubkey::default(),
ErrCode::SystemAlreadyInitialized
);
require!(
ctx.accounts.coef_limit_pda.owner == &Pubkey::default(),
ErrCode::SystemAlreadyInitialized
);
require!(
ctx.accounts.queues_pda.owner == &Pubkey::default(),
ErrCode::SystemAlreadyInitialized
);
require!(
ctx.accounts.inflow_vault_pda.owner == &Pubkey::default(),
ErrCode::SystemAlreadyInitialized
);
let dao_wallet = Pubkey::from_str(settings::DAO_WALLET)
.map_err(|_| error!(PaymentsError::InvalidSettingsWallet))?;
let manager_wallet = Pubkey::from_str(settings::MANAGER_WALLET)
.map_err(|_| error!(PaymentsError::InvalidSettingsWallet))?;
let system_program_ai = ctx.accounts.system_program.to_account_info();
let config = ConfigState {
version: 1,
dao_wallet,
manager_wallet,
inflow_vault: ctx.accounts.inflow_vault_pda.key(),
call_reward_lamports: settings::START_CALL_REWARD_LAMPORTS,
};
create_and_store_state(
ctx.program_id,
&ctx.accounts.payer,
&system_program_ai,
&ctx.accounts.config_pda,
settings::CONFIG_SEED,
settings::CONFIG_SPACE,
&config,
)?;
let coef_limit = CoefLimitState {
version: 1,
coef_ppm: settings::START_COEF_PPM,
limit_lamports: settings::START_LIMIT_LAMPORTS,
};
create_and_store_state(
ctx.program_id,
&ctx.accounts.payer,
&system_program_ai,
&ctx.accounts.coef_limit_pda,
settings::COEF_LIMIT_SEED,
settings::COEF_LIMIT_SPACE,
&coef_limit,
)?;
let queues = QueuesState {
version: 1,
q1_tickets_total: 0,
q1_tickets_paid: 0,
q1_sum_total: 0,
q1_sum_paid: 0,
q2_tickets_total: 0,
q2_tickets_paid: 0,
q2_sum_total: 0,
q2_sum_paid: 0,
};
create_and_store_state(
ctx.program_id,
&ctx.accounts.payer,
&system_program_ai,
&ctx.accounts.queues_pda,
settings::QUEUES_SEED,
settings::QUEUES_SPACE,
&queues,
)?;
let vault = VaultState { version: 1 };
create_and_store_state(
ctx.program_id,
&ctx.accounts.payer,
&system_program_ai,
&ctx.accounts.inflow_vault_pda,
settings::INFLOW_VAULT_SEED,
settings::INFLOW_VAULT_SPACE,
&vault,
)?;
Ok(())
} }
/// invest — в начале читает состояние, в конце сохраняет (логика внутри модуля). pub fn update_coef_limit(ctx: Context<UpdateCoefLimit>, args: UpdateCoefLimitArgs) -> Result<()> {
pub fn invest(ctx: Context<UseState>, amount: u64) -> Result<()> { let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?;
inv_invest(ctx, amount)
}
/// add_bonus — начисление бонусов (обычно от DAO).
/// Для NFT используем расширенный контекст AddBonusCtx (с аккаунтами коллекции и т.п.).
pub fn add_bonus(ctx: Context<AddBonusCtx>, investor: Pubkey, amount: u64) -> Result<()> {
inv_add_bonus(ctx, investor, amount)
}
/// claim — выплата.
pub fn claim(ctx: Context<UseState>) -> Result<()> {
inv_claim(ctx)
}
/// ВРЕМЕННАЯ ФУНКЦИЯ только для тестов (в итоговой версии её не будет):
/// deleteInit — удалить PDA из init и вернуть ренту подписанту.
pub fn delete_init(ctx: Context<DeleteInit>) -> Result<()> {
let program_id = ctx.program_id;
// PDA по тем же сид/бамп, что и в init
let (expected_pda, _bump) = Pubkey::find_program_address(&[PDA_SEED_PREFIX], program_id);
require_keys_eq!( require_keys_eq!(
expected_pda, config.manager_wallet,
ctx.accounts.state_pda.key(), ctx.accounts.signer.key(),
PaymentsError::UnauthorizedManager
);
require!(args.coef_ppm > 0, PaymentsError::InvalidCoefficient);
require!(args.limit_lamports > 0, PaymentsError::InvalidLimit);
let mut coef_limit = read_state::<CoefLimitState>(&ctx.accounts.coef_limit_pda)?;
coef_limit.coef_ppm = args.coef_ppm;
coef_limit.limit_lamports = args.limit_lamports;
write_state(&ctx.accounts.coef_limit_pda, &coef_limit)?;
Ok(())
}
pub fn buy_ticket(ctx: Context<BuyTicket>, args: BuyTicketArgs) -> Result<()> {
require!(args.amount_lamports > 0, PaymentsError::InvalidAmount);
let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?;
let coef_limit = read_state::<CoefLimitState>(&ctx.accounts.coef_limit_pda)?;
let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
require_keys_eq!(
ctx.accounts.dao_wallet.key(),
config.dao_wallet,
PaymentsError::InvalidDaoWallet
);
let current_debt = queues
.q1_sum_total
.checked_sub(queues.q1_sum_paid)
.ok_or(error!(ErrCode::MathOverflow))?;
require!(
current_debt < coef_limit.limit_lamports,
PaymentsError::QueueTemporarilyPaused
);
let ticket_index = queues
.q1_tickets_total
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?;
let (expected_ticket_pda, ticket_bump) = find_ticket_pda(ctx.program_id, 1, ticket_index);
require_keys_eq!(
expected_ticket_pda,
ctx.accounts.ticket_pda.key(),
ErrCode::InvalidPdaAddress
);
require!(
ctx.accounts.ticket_pda.owner == &Pubkey::default(),
ErrCode::PdaAlreadyExists
);
let payout_lamports = args
.amount_lamports
.checked_mul(coef_limit.coef_ppm)
.ok_or(error!(ErrCode::MathOverflow))?
/ settings::COEF_SCALE_PPM;
require!(payout_lamports > 0, PaymentsError::InvalidPayoutAmount);
let ix = system_instruction::transfer(
ctx.accounts.signer.key,
ctx.accounts.dao_wallet.key,
args.amount_lamports,
);
invoke(
&ix,
&[
ctx.accounts.signer.clone(),
ctx.accounts.dao_wallet.clone(),
ctx.accounts.system_program.to_account_info(),
],
)?;
let ticket = TicketState {
version: 1,
queue_id: 1,
index: ticket_index,
is_paid: false,
recipient_wallet: args.recipient_wallet,
payout_lamports,
debt_before_lamports: current_debt,
};
create_state_with_seeds(
ctx.program_id,
&ctx.accounts.signer,
&ctx.accounts.system_program.to_account_info(),
&ctx.accounts.ticket_pda,
&[
settings::Q1_TICKET_SEED,
&ticket_index.to_le_bytes(),
&[ticket_bump],
],
settings::TICKET_SPACE,
&ticket,
)?;
queues.q1_tickets_total = ticket_index;
queues.q1_sum_total = queues
.q1_sum_total
.checked_add(payout_lamports)
.ok_or(error!(ErrCode::MathOverflow))?;
write_state(&ctx.accounts.queues_pda, &queues)?;
Ok(())
}
pub fn step_payout(ctx: Context<StepPayout>) -> Result<()> {
let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?;
let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
let _vault_state = read_state::<VaultState>(&ctx.accounts.inflow_vault_pda)?;
require_keys_eq!(
ctx.accounts.dao_wallet.key(),
config.dao_wallet,
PaymentsError::InvalidDaoWallet
);
require_keys_eq!(
ctx.accounts.inflow_vault_pda.key(),
config.inflow_vault,
PaymentsError::InvalidInflowVault
);
let q1_pending = queues
.q1_tickets_total
.checked_sub(queues.q1_tickets_paid)
.ok_or(error!(ErrCode::MathOverflow))?;
let q2_pending = queues
.q2_tickets_total
.checked_sub(queues.q2_tickets_paid)
.ok_or(error!(ErrCode::MathOverflow))?;
if q1_pending == 0 {
if q2_pending > 0 {
return Err(error!(PaymentsError::SecondQueueNotImplemented));
}
transfer_all_available_to_dao(
&ctx.accounts.inflow_vault_pda,
&ctx.accounts.dao_wallet,
)?;
return Ok(());
}
let next_index = queues
.q1_tickets_paid
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?;
let (expected_ticket_pda, _) = find_ticket_pda(ctx.program_id, 1, next_index);
require_keys_eq!(
expected_ticket_pda,
ctx.accounts.next_ticket_pda.key(),
ErrCode::InvalidPdaAddress ErrCode::InvalidPdaAddress
); );
// Рента уйдёт на счёт подписанта (signer) let mut ticket = read_state::<TicketState>(&ctx.accounts.next_ticket_pda)?;
common::utils::delete_pda_return_rent( require!(ticket.queue_id == 1, PaymentsError::InvalidTicketQueue);
&ctx.accounts.state_pda.to_account_info(), require!(ticket.index == next_index, PaymentsError::InvalidTicketIndex);
&ctx.accounts.signer.to_account_info(), require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid);
require_keys_eq!(
ctx.accounts.ticket_recipient_wallet.key(),
ticket.recipient_wallet,
PaymentsError::InvalidTicketRecipient
);
let needed = ticket
.payout_lamports
.checked_add(ticket.payout_lamports)
.and_then(|v| v.checked_add(config.call_reward_lamports))
.ok_or(error!(ErrCode::MathOverflow))?;
require!(
available_vault_lamports(&ctx.accounts.inflow_vault_pda)? >= needed,
PaymentsError::NotEnoughInflowForStep
);
transfer_from_vault(
&ctx.accounts.inflow_vault_pda,
&ctx.accounts.ticket_recipient_wallet,
ticket.payout_lamports,
)?;
transfer_from_vault(
&ctx.accounts.inflow_vault_pda,
&ctx.accounts.dao_wallet,
ticket.payout_lamports,
)?;
transfer_from_vault(
&ctx.accounts.inflow_vault_pda,
&ctx.accounts.signer,
config.call_reward_lamports,
)?;
ticket.is_paid = true;
write_state(&ctx.accounts.next_ticket_pda, &ticket)?;
queues.q1_tickets_paid = queues
.q1_tickets_paid
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?;
queues.q1_sum_paid = queues
.q1_sum_paid
.checked_add(ticket.payout_lamports)
.ok_or(error!(ErrCode::MathOverflow))?;
write_state(&ctx.accounts.queues_pda, &queues)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct Init<'info> {
/// CHECK: подписант и плательщик, проверяется атрибутом `signer`.
#[account(mut, signer)]
pub payer: AccountInfo<'info>,
/// CHECK: PDA конфига, адрес проверяется вручную в `ensure_expected_pdas`.
#[account(mut)]
pub config_pda: AccountInfo<'info>,
/// CHECK: PDA коэффициента/лимита, адрес проверяется вручную в `ensure_expected_pdas`.
#[account(mut)]
pub coef_limit_pda: AccountInfo<'info>,
/// CHECK: PDA состояния очередей, адрес проверяется вручную в `ensure_expected_pdas`.
#[account(mut)]
pub queues_pda: AccountInfo<'info>,
/// CHECK: PDA inflow-вольта, адрес проверяется вручную в `ensure_expected_pdas`.
#[account(mut)]
pub inflow_vault_pda: AccountInfo<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct UpdateCoefLimit<'info> {
/// CHECK: подписант-менеджер, проверяется атрибутом `signer` и сверкой адреса в коде.
#[account(mut, signer)]
pub signer: AccountInfo<'info>,
/// CHECK: PDA конфига, читается и валидируется вручную.
#[account(mut)]
pub config_pda: AccountInfo<'info>,
/// CHECK: PDA коэффициента/лимита, читается и валидируется вручную.
#[account(mut)]
pub coef_limit_pda: AccountInfo<'info>,
}
#[derive(Accounts)]
pub struct BuyTicket<'info> {
/// CHECK: подписант-покупатель, проверяется атрибутом `signer`.
#[account(mut, signer)]
pub signer: AccountInfo<'info>,
/// CHECK: PDA конфига, читается и валидируется вручную.
#[account(mut)]
pub config_pda: AccountInfo<'info>,
/// CHECK: PDA коэффициента/лимита, читается и валидируется вручную.
#[account(mut)]
pub coef_limit_pda: AccountInfo<'info>,
/// CHECK: PDA очередей, читается и валидируется вручную.
#[account(mut)]
pub queues_pda: AccountInfo<'info>,
/// CHECK: PDA тикета, адрес и состояние (должен быть пустым) проверяются вручную.
#[account(mut)]
pub ticket_pda: AccountInfo<'info>,
/// CHECK: DAO-кошелек, адрес сверяется с конфигом вручную.
#[account(mut)]
pub dao_wallet: AccountInfo<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct StepPayout<'info> {
/// CHECK: подписант-вызвавший шаг выплат, проверяется атрибутом `signer`.
#[account(mut, signer)]
pub signer: AccountInfo<'info>,
/// CHECK: PDA конфига, читается и валидируется вручную.
#[account(mut)]
pub config_pda: AccountInfo<'info>,
/// CHECK: PDA очередей, читается и валидируется вручную.
#[account(mut)]
pub queues_pda: AccountInfo<'info>,
/// CHECK: PDA inflow-вольта, адрес сверяется с конфигом вручную.
#[account(mut)]
pub inflow_vault_pda: AccountInfo<'info>,
/// CHECK: PDA следующего тикета, адрес и содержимое валидируются вручную.
#[account(mut)]
pub next_ticket_pda: AccountInfo<'info>,
/// CHECK: кошелек получателя тикета, адрес сверяется с полем тикета вручную.
#[account(mut)]
pub ticket_recipient_wallet: AccountInfo<'info>,
/// CHECK: DAO-кошелек, адрес сверяется с конфигом вручную.
#[account(mut)]
pub dao_wallet: AccountInfo<'info>,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct UpdateCoefLimitArgs {
pub coef_ppm: u64,
pub limit_lamports: u64,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct BuyTicketArgs {
pub amount_lamports: u64,
pub recipient_wallet: Pubkey,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ConfigState {
pub version: u8,
pub dao_wallet: Pubkey,
pub manager_wallet: Pubkey,
pub inflow_vault: Pubkey,
pub call_reward_lamports: u64,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct CoefLimitState {
pub version: u8,
pub coef_ppm: u64,
pub limit_lamports: u64,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct QueuesState {
pub version: u8,
pub q1_tickets_total: u64,
pub q1_tickets_paid: u64,
pub q1_sum_total: u64,
pub q1_sum_paid: u64,
pub q2_tickets_total: u64,
pub q2_tickets_paid: u64,
pub q2_sum_total: u64,
pub q2_sum_paid: u64,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct TicketState {
pub version: u8,
pub queue_id: u8,
pub index: u64,
pub is_paid: bool,
pub recipient_wallet: Pubkey,
pub payout_lamports: u64,
pub debt_before_lamports: u64,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct VaultState {
pub version: u8,
}
#[error_code]
pub enum PaymentsError {
#[msg("Ошибка в адресах кошельков из настроек программы")]
InvalidSettingsWallet,
#[msg("Недостаточно данных PDA")]
EmptyState,
#[msg("Неверный inflow vault")]
InvalidInflowVault,
#[msg("Неверный DAO кошелек")]
InvalidDaoWallet,
#[msg("Управляющий кошелек не авторизован")]
UnauthorizedManager,
#[msg("Некорректный коэффициент")]
InvalidCoefficient,
#[msg("Некорректный лимит")]
InvalidLimit,
#[msg("Некорректная сумма")]
InvalidAmount,
#[msg("Очередь временно приостановлена: достигнут лимит")]
QueueTemporarilyPaused,
#[msg("Некорректная сумма выплаты")]
InvalidPayoutAmount,
#[msg("Недостаточно средств на inflow vault для шага выплаты")]
NotEnoughInflowForStep,
#[msg("Тикет уже выплачен")]
TicketAlreadyPaid,
#[msg("Неверный получатель тикета")]
InvalidTicketRecipient,
#[msg("Неверный номер тикета")]
InvalidTicketIndex,
#[msg("Неверный тип очереди у тикета")]
InvalidTicketQueue,
#[msg("Вторая очередь пока не реализована для выплат")]
SecondQueueNotImplemented,
}
fn ensure_expected_pdas(program_id: &Pubkey, accounts: &Init) -> Result<()> {
let (config, _) = find_single_pda(program_id, settings::CONFIG_SEED);
let (coef, _) = find_single_pda(program_id, settings::COEF_LIMIT_SEED);
let (queues, _) = find_single_pda(program_id, settings::QUEUES_SEED);
let (inflow, _) = find_single_pda(program_id, settings::INFLOW_VAULT_SEED);
require_keys_eq!(config, accounts.config_pda.key(), ErrCode::InvalidPdaAddress);
require_keys_eq!(
coef,
accounts.coef_limit_pda.key(),
ErrCode::InvalidPdaAddress
);
require_keys_eq!(
queues,
accounts.queues_pda.key(),
ErrCode::InvalidPdaAddress
);
require_keys_eq!(
inflow,
accounts.inflow_vault_pda.key(),
ErrCode::InvalidPdaAddress
);
Ok(())
}
fn find_single_pda(program_id: &Pubkey, seed: &[u8]) -> (Pubkey, u8) {
Pubkey::find_program_address(&[seed], program_id)
}
fn find_ticket_pda(program_id: &Pubkey, queue_id: u8, index: u64) -> (Pubkey, u8) {
let idx = index.to_le_bytes();
let seed = if queue_id == 1 {
settings::Q1_TICKET_SEED
} else {
settings::Q2_TICKET_SEED
};
Pubkey::find_program_address(&[seed, &idx], program_id)
}
fn create_and_store_state<'info, T: AnchorSerialize>(
program_id: &Pubkey,
payer: &AccountInfo<'info>,
system_program: &AccountInfo<'info>,
pda: &AccountInfo<'info>,
seed: &[u8],
space: usize,
state: &T,
) -> Result<()> {
let (_, bump) = find_single_pda(program_id, seed);
create_state_with_seeds(
program_id, program_id,
payer,
system_program,
pda,
&[seed, &[bump]],
space,
state,
) )
} }
fn create_state_with_seeds<'info, T: AnchorSerialize>(
program_id: &Pubkey,
payer: &AccountInfo<'info>,
system_program: &AccountInfo<'info>,
pda: &AccountInfo<'info>,
seeds: &[&[u8]],
space: usize,
state: &T,
) -> Result<()> {
create_pda(
pda,
payer,
system_program,
program_id,
seeds,
space as u64,
)?;
write_state(pda, state)
} }
// ============================================== fn write_state<T: AnchorSerialize>(pda: &AccountInfo, state: &T) -> Result<()> {
// Контексты вне #[program] let bytes = state
// ============================================== .try_to_vec()
.map_err(|_| error!(ErrCode::DeserializationError))?;
/// Контекст для deleteInit (временный для тестов) write_to_pda(pda, &bytes)
#[derive(Accounts)]
pub struct DeleteInit<'info> {
/// Подписант транзакции — ПОЛУЧАТЕЛЬ ренты
#[account(mut)]
pub signer: Signer<'info>,
/// Тот самый PDA из init
/// CHECK: адрес валидируем в хендлере по сид-у
#[account(mut)]
pub state_pda: UncheckedAccount<'info>,
/// Системная программа
pub system_program: Program<'info, System>,
} }
/// Контекст для add_bonus: полный набор аккаунтов для операций с NFT и коллекцией. fn read_state<T: AnchorDeserialize>(pda: &AccountInfo) -> Result<T> {
/// (Комменты по стилю проекта оставлены.) let raw = safe_read_pda(pda);
#[derive(Accounts)] require!(!raw.is_empty(), PaymentsError::EmptyState);
pub struct AddBonusCtx<'info> { let mut slice: &[u8] = &raw;
/// Любой платящий/подписант (в реальном коде — свои проверки). T::deserialize(&mut slice).map_err(|_| error!(ErrCode::DeserializationError))
#[account(mut)] }
pub signer: Signer<'info>,
fn available_vault_lamports(vault: &AccountInfo) -> Result<u64> {
/// Тот же PDA с состоянием (должен уже существовать). let total = vault.lamports();
/// CHECK: проверяется вручную по адресу let rent_min = Rent::get()?.minimum_balance(vault.data_len());
#[account(mut)] Ok(total.saturating_sub(rent_min))
pub state_pda: UncheckedAccount<'info>, }
// --- аккаунты минтимого NFT --- fn transfer_from_vault(vault: &AccountInfo, recipient: &AccountInfo, amount: u64) -> Result<()> {
/// Mint создаваемого NFT (должен быть создан заранее: decimals=0, mint_authority=signer, freeze_authority=signer) if amount == 0 {
/// CHECK return Ok(());
#[account(mut)] }
pub mint_pda: UncheckedAccount<'info>, let mut vault_lamports = vault.try_borrow_mut_lamports()?;
let mut recipient_lamports = recipient.try_borrow_mut_lamports()?;
/// ATA получателя (может быть предсоздан тестом) require!(**vault_lamports >= amount, PaymentsError::NotEnoughInflowForStep);
/// CHECK **vault_lamports = vault_lamports
#[account(mut)] .checked_sub(amount)
pub recipient_ata: UncheckedAccount<'info>, .ok_or(error!(ErrCode::MathOverflow))?;
/// Владелец ATA (инвестор) **recipient_lamports = recipient_lamports
/// CHECK .checked_add(amount)
pub recipient_owner: UncheckedAccount<'info>, .ok_or(error!(ErrCode::MathOverflow))?;
Ok(())
// --- аккаунты коллекции (уже созданной заранее) --- }
/// CHECK
pub collection_mint: UncheckedAccount<'info>, fn transfer_all_available_to_dao(vault: &AccountInfo, dao_wallet: &AccountInfo) -> Result<()> {
/// CHECK let available = available_vault_lamports(vault)?;
#[account(mut)] transfer_from_vault(vault, dao_wallet, available)
pub collection_metadata_pda: UncheckedAccount<'info>,
/// CHECK
#[account(mut)]
pub collection_master_edition_pda: UncheckedAccount<'info>,
/// Апдейтер коллекции (update authority)
pub collection_update_authority: Signer<'info>,
// --- metadata + master edition для создаваемого NFT ---
/// CHECK
#[account(mut)]
pub metadata_pda: UncheckedAccount<'info>,
/// CHECK
#[account(mut)]
pub master_edition_pda: UncheckedAccount<'info>,
// --- программы ---
/// CHECK: проверяется по ID внутри nft.rs
pub token_metadata_program: UncheckedAccount<'info>,
pub token_program: Program<'info, anchor_spl::token::Token>,
pub associated_token_program: Program<'info, anchor_spl::associated_token::AssociatedToken>,
pub system_program: Program<'info, System>,
} }

View File

@ -0,0 +1,20 @@
pub const CONFIG_SEED: &[u8] = b"shine_payments_v2_config";
pub const COEF_LIMIT_SEED: &[u8] = b"shine_payments_v2_coef_limit";
pub const QUEUES_SEED: &[u8] = b"shine_payments_v2_queues";
pub const INFLOW_VAULT_SEED: &[u8] = b"shine_payments_v2_inflow_vault";
pub const Q1_TICKET_SEED: &[u8] = b"shine_payments_v2_q1_ticket";
pub const Q2_TICKET_SEED: &[u8] = b"shine_payments_v2_q2_ticket";
pub const CONFIG_SPACE: usize = 8 + 160;
pub const COEF_LIMIT_SPACE: usize = 8 + 96;
pub const QUEUES_SPACE: usize = 8 + 192;
pub const INFLOW_VAULT_SPACE: usize = 8 + 32;
pub const TICKET_SPACE: usize = 8 + 160;
pub const COEF_SCALE_PPM: u64 = 1_000_000;
pub const START_COEF_PPM: u64 = 5_000_000; // 5.0
pub const START_LIMIT_LAMPORTS: u64 = 100 * 1_000_000_000; // 100 SOL
pub const START_CALL_REWARD_LAMPORTS: u64 = 8_000_000; // 0.008 SOL
pub const DAO_WALLET: &str = "6bFc5Gz5qF172GQhK5HpDbWs8F6qcSxdHn5XqAstf1fY";
pub const MANAGER_WALLET: &str = "4yzHKs2zFXpyqqCETe8KpAs4xhEo4QhJ2ybyTgRZphZv";

View File

@ -0,0 +1,385 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Тех. инструменты — Shine Payments Devnet</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; }
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 8px; min-width: 180px; }
button { padding: 8px 12px; cursor: pointer; }
.muted { color: #666; }
.ok { color: #0a7a3c; }
.warn { color: #9f5f00; }
.err { color: #b30000; white-space: pre-wrap; }
.paid { color: #0a7a3c; font-weight: 700; }
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 6px; text-align: left; font-size: 14px; }
</style>
</head>
<body>
<h1>Техническая страница (Devnet)</h1>
<div class="muted">Программа: <code id="programId"></code></div>
<div class="panel">
<div class="row">
<button id="connectBtn">Подключить кошелек</button>
<button id="refreshBtn">Обновить всё</button>
<button id="initBtn">Init (один раз)</button>
</div>
<div id="walletInfo" class="muted"></div>
<div id="initResult" class="muted"></div>
</div>
<div class="panel">
<h3>Коэффициент и лимит</h3>
<div class="muted">Право изменения: <code id="managerAllowed">загрузка...</code></div>
<div class="row">
<label>Коэффициент (например 5): <input id="coefInput" value="5" /></label>
<label>Лимит (SOL): <input id="limitInput" value="100" /></label>
<button id="updateCoefBtn">Обновить</button>
</div>
<div id="updateResult" class="muted"></div>
</div>
<div class="panel">
<h3>Адреса и балансы</h3>
<div id="balances" class="muted">Загрузка...</div>
</div>
<div class="panel">
<h3>Очередь 1 (все билеты)</h3>
<div class="row">
<button id="loadQueueBtn">Показать всю очередь</button>
</div>
<div id="queueTable" class="muted"></div>
</div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
<script>
const PROGRAM_ID = new solanaWeb3.PublicKey("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE");
const RPC_URL = "https://api.devnet.solana.com";
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = {
config: "shine_payments_v2_config",
coef: "shine_payments_v2_coef_limit",
queues: "shine_payments_v2_queues",
inflow: "shine_payments_v2_inflow_vault",
ticketQ1: "shine_payments_v2_q1_ticket",
};
let walletPubkey = null;
let cache = null;
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
function utf8(s) { return new TextEncoder().encode(s); }
function u64ToBytes(v) {
let x = BigInt(v);
const out = new Uint8Array(8);
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
return out;
}
function readU64(data, offset) {
let x = 0n;
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
return x;
}
function concat(...parts) {
const len = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(len);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
function trimZeros(v) {
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
}
function lamportsToSolStr(l) {
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
}
function solToLamports(solStr) {
const v = Number(solStr);
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
return BigInt(Math.round(v * 1_000_000_000));
}
async function ixDiscriminator(name) {
const msg = utf8("global:" + name);
const hash = await crypto.subtle.digest("SHA-256", msg);
return new Uint8Array(hash).slice(0, 8);
}
function isUnauthorizedManager(msg) {
const s = String(msg || "").toLowerCase();
return s.includes("unauthorizedmanager") || s.includes("0x1774");
}
function parseConfig(data) {
let o = 0;
const version = data[o++];
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const reward = readU64(data, o); o += 8;
return { version, dao, manager, inflow, reward };
}
function parseCoef(data) {
let o = 0;
const version = data[o++];
const coefPpm = readU64(data, o); o += 8;
const limit = readU64(data, o); o += 8;
return { version, coefPpm, limit };
}
function parseQueues(data) {
let o = 0;
const version = data[o++];
const q1Total = readU64(data, o); o += 8;
const q1Paid = readU64(data, o); o += 8;
const q1SumTotal = readU64(data, o); o += 8;
const q1SumPaid = readU64(data, o); o += 8;
const q2Total = readU64(data, o); o += 8;
const q2Paid = readU64(data, o); o += 8;
const q2SumTotal = readU64(data, o); o += 8;
const q2SumPaid = readU64(data, o); o += 8;
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
}
function parseTicket(data) {
let o = 0;
const version = data[o++];
const queueId = data[o++];
const index = readU64(data, o); o += 8;
const isPaid = data[o++] === 1;
const recipient = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const payout = readU64(data, o); o += 8;
const debtBefore = readU64(data, o); o += 8;
return { version, queueId, index, isPaid, recipient, payout, debtBefore };
}
function getProvider() {
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
return window.solana;
}
async function connectWallet() {
const provider = getProvider();
const r = await provider.connect();
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
await refreshAll();
}
async function sendInstruction(ix) {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
const tx = new solanaWeb3.Transaction().add(ix);
tx.feePayer = walletPubkey;
const bh = await connection.getLatestBlockhash("confirmed");
tx.recentBlockhash = bh.blockhash;
const signed = await provider.signTransaction(tx);
const sig = await connection.sendRawTransaction(signed.serialize(), { skipPreflight: false });
await connection.confirmTransaction({ signature: sig, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight }, "confirmed");
return sig;
}
function derivePdas() {
const [configPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID);
const [coefPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.coef)], PROGRAM_ID);
const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
const [inflowPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.inflow)], PROGRAM_ID);
return { configPda, coefPda, queuesPda, inflowPda };
}
function q1TicketPda(index) {
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(index)], PROGRAM_ID);
return pda;
}
async function loadCore() {
const pdas = derivePdas();
const [cfgAi, coefAi, qAi, inflowAi] = await Promise.all([
connection.getAccountInfo(pdas.configPda, "confirmed"),
connection.getAccountInfo(pdas.coefPda, "confirmed"),
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
connection.getAccountInfo(pdas.inflowPda, "confirmed"),
]);
if (!cfgAi || !coefAi || !qAi || !inflowAi) {
cache = { pdas, notInited: true };
return cache;
}
const config = parseConfig(cfgAi.data);
const coef = parseCoef(coefAi.data);
const queues = parseQueues(qAi.data);
const [daoBal, inflowRent] = await Promise.all([
connection.getBalance(config.dao, "confirmed"),
connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed"),
]);
cache = {
pdas,
config,
coef,
queues,
inflowLamports: BigInt(inflowAi.lamports),
inflowAvailable: BigInt(Math.max(0, inflowAi.lamports - inflowRent)),
daoBalance: BigInt(daoBal),
};
return cache;
}
async function refreshBalances() {
const el = document.getElementById("balances");
try {
const core = await loadCore();
if (core.notInited) {
el.innerHTML = `<span class="warn">PDA ещё не инициализированы.</span>`;
return;
}
const coefText = trimZeros((Number(core.coef.coefPpm) / 1_000_000).toFixed(6));
document.getElementById("managerAllowed").textContent = core.config.manager.toBase58();
el.innerHTML = `
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
<div>Inflow vault: <code>${core.config.inflow.toBase58()}</code></div>
<div class="muted">Inflow vault — это входящий PDA-кошелек выплат программы.</div>
<div>Manager: <code>${core.config.manager.toBase58()}</code></div>
<div>Награда за шаг: <b>${lamportsToSolStr(core.config.reward)} SOL</b></div>
<div>Коэффициент: <b>${coefText}</b>, лимит: <b>${lamportsToSolStr(core.coef.limit)} SOL</b></div>
<div>Баланс DAO: <b>${lamportsToSolStr(core.daoBalance)} SOL</b></div>
<div>Баланс inflow vault: <b>${lamportsToSolStr(core.inflowLamports)} SOL</b></div>
<div>Доступно в inflow (сверх ренты): <b>${lamportsToSolStr(core.inflowAvailable)} SOL</b></div>
<div>Q1: total=${core.queues.q1Total}, paid=${core.queues.q1Paid}, sum_total=${lamportsToSolStr(core.queues.q1SumTotal)} SOL, sum_paid=${lamportsToSolStr(core.queues.q1SumPaid)} SOL</div>
<div>Q2: total=${core.queues.q2Total}, paid=${core.queues.q2Paid}, sum_total=${lamportsToSolStr(core.queues.q2SumTotal)} SOL, sum_paid=${lamportsToSolStr(core.queues.q2SumPaid)} SOL</div>
`;
} catch (e) {
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
document.getElementById("managerAllowed").textContent = "не определен";
}
}
async function runInit() {
const out = document.getElementById("initResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) {
await connectWallet();
} else if (!provider.isConnected) {
await provider.connect();
}
const pdas = derivePdas();
const disc = await ixDiscriminator("init");
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: pdas.configPda, isSigner: false, isWritable: true },
{ pubkey: pdas.coefPda, isSigner: false, isWritable: true },
{ pubkey: pdas.queuesPda, isSigner: false, isWritable: true },
{ pubkey: pdas.inflowPda, isSigner: false, isWritable: true },
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data: disc });
const sig = await sendInstruction(ix);
out.innerHTML = `<span class="ok">Init выполнен. Tx: <code>${sig}</code></span>`;
await refreshAll();
} catch (e) {
const raw = String(e.message || e);
if (isUnauthorizedManager(raw)) {
const mgr = document.getElementById("managerAllowed").textContent;
out.innerHTML = `<span class="warn">Вы подключены не под тем аккаунтом. Изменение доступно только управляющему кошельку: <code>${mgr}</code>.</span>`;
return;
}
out.innerHTML = `<span class="err">${raw}</span>`;
}
}
async function updateCoefLimit() {
const out = document.getElementById("updateResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) {
await connectWallet();
} else if (!provider.isConnected) {
await provider.connect();
}
const core = await loadCore();
if (core.notInited) throw new Error("Сначала выполните init");
const coef = Number(document.getElementById("coefInput").value.trim());
if (!Number.isFinite(coef) || coef <= 0) throw new Error("Некорректный коэффициент");
const coefPpm = BigInt(Math.round(coef * 1_000_000));
const limitLamports = solToLamports(document.getElementById("limitInput").value.trim());
const disc = await ixDiscriminator("update_coef_limit");
const data = concat(disc, u64ToBytes(coefPpm), u64ToBytes(limitLamports));
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: core.pdas.configPda, isSigner: false, isWritable: true },
{ pubkey: core.pdas.coefPda, isSigner: false, isWritable: true },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix);
out.innerHTML = `<span class="ok">Обновлено. Tx: <code>${sig}</code></span>`;
await refreshAll();
} catch (e) {
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
async function showQueue() {
const out = document.getElementById("queueTable");
out.textContent = "Загрузка...";
try {
const core = await loadCore();
if (core.notInited) throw new Error("Сначала выполните init");
if (core.queues.q1Total === 0n) {
out.innerHTML = `<span class="muted">Очередь 1 пока пустая.</span>`;
return;
}
const rows = [];
for (let i = 1n; i <= core.queues.q1Total; i++) {
const pda = q1TicketPda(i);
const ai = await connection.getAccountInfo(pda, "confirmed");
if (!ai) {
rows.push(`<tr><td>${i.toString()}</td><td colspan="6" class="err">PDA не найден</td></tr>`);
continue;
}
const t = parseTicket(ai.data);
rows.push(`
<tr>
<td>${t.index.toString()}</td>
<td>${t.isPaid ? '<span class="paid">да</span>' : "нет"}</td>
<td><code>${pda.toBase58()}</code></td>
<td><code>${t.recipient.toBase58()}</code></td>
<td>${lamportsToSolStr(t.payout)} SOL</td>
<td>${lamportsToSolStr(t.debtBefore)} SOL</td>
<td>${t.queueId}</td>
</tr>
`);
}
out.innerHTML = `
<table>
<thead>
<tr>
<th>#</th><th>Выплачен</th><th>PDA</th><th>Получатель</th><th>Сумма</th><th>Debt Before</th><th>Очередь</th>
</tr>
</thead>
<tbody>${rows.join("")}</tbody>
</table>
`;
} catch (e) {
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
async function refreshAll() {
await refreshBalances();
}
document.getElementById("connectBtn").addEventListener("click", connectWallet);
document.getElementById("refreshBtn").addEventListener("click", refreshAll);
document.getElementById("initBtn").addEventListener("click", runInit);
document.getElementById("updateCoefBtn").addEventListener("click", updateCoefLimit);
document.getElementById("loadQueueBtn").addEventListener("click", showQueue);
refreshAll();
</script>
</body>
</html>

View File

@ -0,0 +1,260 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Покупка билета — Shine Payments Devnet</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; }
h1 { margin-bottom: 8px; }
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 8px; min-width: 220px; }
button { padding: 8px 12px; cursor: pointer; }
.muted { color: #666; }
.ok { color: #0a7a3c; }
.warn { color: #9f5f00; }
.err { color: #b30000; white-space: pre-wrap; }
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; }
</style>
</head>
<body>
<h1>Покупка билета (Devnet)</h1>
<div class="muted">Программа: <code id="programId"></code></div>
<div class="panel">
<div class="row">
<button id="connectBtn">Подключить кошелек</button>
<button id="refreshBtn">Обновить состояние</button>
</div>
<div id="walletInfo" class="muted"></div>
</div>
<div class="panel">
<h3>Текущее состояние</h3>
<div id="stateInfo" class="muted">Загрузка...</div>
</div>
<div class="panel">
<h3>Покупка билета в 1-й очереди</h3>
<div class="row">
<label>Сумма (SOL): <input id="amountSol" value="0.1" /></label>
<label>Кошелек для выплаты: <input id="recipient" placeholder="по умолчанию ваш кошелек" /></label>
</div>
<div class="row">
<button id="buyBtn">Купить билет</button>
</div>
<div id="buyResult" class="muted"></div>
</div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
<script>
const PROGRAM_ID = new solanaWeb3.PublicKey("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE");
const RPC_URL = "https://api.devnet.solana.com";
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = {
config: "shine_payments_v2_config",
coef: "shine_payments_v2_coef_limit",
queues: "shine_payments_v2_queues",
ticketQ1: "shine_payments_v2_q1_ticket",
};
const COEF_SCALE = 1_000_000n;
let walletPubkey = null;
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
function utf8(s) { return new TextEncoder().encode(s); }
function u64ToBytes(v) {
let x = BigInt(v);
const out = new Uint8Array(8);
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
return out;
}
function readU64(data, offset) {
let x = 0n;
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
return x;
}
function concat(...parts) {
const len = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(len);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
function trimZeros(v) {
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
}
function lamportsToSolStr(l) {
const sol = Number(l) / 1_000_000_000;
return trimZeros(sol.toFixed(9));
}
function solToLamports(solStr) {
const v = Number(solStr);
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
return BigInt(Math.round(v * 1_000_000_000));
}
async function ixDiscriminator(name) {
const msg = utf8("global:" + name);
const hash = await crypto.subtle.digest("SHA-256", msg);
return new Uint8Array(hash).slice(0, 8);
}
function parseConfig(data) {
let o = 0;
const version = data[o++];
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
const reward = readU64(data, o); o += 8;
return { version, dao, manager, inflow, reward };
}
function parseCoef(data) {
let o = 0;
const version = data[o++];
const coefPpm = readU64(data, o); o += 8;
const limit = readU64(data, o); o += 8;
return { version, coefPpm, limit };
}
function parseQueues(data) {
let o = 0;
const version = data[o++];
const q1Total = readU64(data, o); o += 8;
const q1Paid = readU64(data, o); o += 8;
const q1SumTotal = readU64(data, o); o += 8;
const q1SumPaid = readU64(data, o); o += 8;
const q2Total = readU64(data, o); o += 8;
const q2Paid = readU64(data, o); o += 8;
const q2SumTotal = readU64(data, o); o += 8;
const q2SumPaid = readU64(data, o); o += 8;
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
}
function getProvider() {
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
return window.solana;
}
async function connectWallet() {
const provider = getProvider();
const r = await provider.connect();
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
await refreshState();
}
async function sendInstruction(ix) {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
const tx = new solanaWeb3.Transaction().add(ix);
tx.feePayer = walletPubkey;
const bh = await connection.getLatestBlockhash("confirmed");
tx.recentBlockhash = bh.blockhash;
const signed = await provider.signTransaction(tx);
const sig = await connection.sendRawTransaction(signed.serialize(), { skipPreflight: false });
await connection.confirmTransaction({ signature: sig, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight }, "confirmed");
return sig;
}
async function derivePdas() {
const [configPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID);
const [coefPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.coef)], PROGRAM_ID);
const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
return { configPda, coefPda, queuesPda };
}
async function loadCoreState() {
const pdas = await derivePdas();
const [cfgAi, coefAi, queuesAi] = await Promise.all([
connection.getAccountInfo(pdas.configPda, "confirmed"),
connection.getAccountInfo(pdas.coefPda, "confirmed"),
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
]);
if (!cfgAi || !coefAi || !queuesAi) throw new Error("PDA не инициализированы. Сначала выполните init на странице админки.");
const config = parseConfig(cfgAi.data);
const coef = parseCoef(coefAi.data);
const queues = parseQueues(queuesAi.data);
return { pdas, config, coef, queues };
}
async function refreshState() {
const el = document.getElementById("stateInfo");
try {
const { config, coef, queues } = await loadCoreState();
const currentDebt = queues.q1SumTotal - queues.q1SumPaid;
const pendingTickets = queues.q1Total - queues.q1Paid;
const remaining = coef.limit > currentDebt ? coef.limit - currentDebt : 0n;
const coefText = trimZeros((Number(coef.coefPpm) / Number(COEF_SCALE)).toFixed(6));
const paused = currentDebt >= coef.limit;
el.innerHTML = `
<div>DAO: <code>${config.dao}</code></div>
<div>Inflow vault: <code>${config.inflow}</code></div>
<div class="muted">Inflow vault — это входящий PDA-кошелек выплат программы.</div>
<div>Коэффициент: <b>${coefText}</b></div>
<div>Лимит очереди (1): <b>${lamportsToSolStr(coef.limit)} SOL</b></div>
<div>Текущий долг очереди (1): <b>${lamportsToSolStr(currentDebt)} SOL</b></div>
<div>Билетов в ожидании до вас сейчас: <b>${pendingTickets.toString()}</b></div>
<div>Осталось до изменения коэффициента/лимита: <b>${lamportsToSolStr(remaining)} SOL</b></div>
<div class="${paused ? "warn" : "ok"}">${paused ? "Покупка временно приостановлена: очередь заполнена." : "Покупка доступна."}</div>
`;
} catch (e) {
el.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
}
}
async function buyTicket() {
const out = document.getElementById("buyResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) {
await connectWallet();
} else if (!provider.isConnected) {
await provider.connect();
}
const { pdas, config, coef, queues } = await loadCoreState();
const currentDebt = queues.q1SumTotal - queues.q1SumPaid;
if (currentDebt >= coef.limit) {
out.innerHTML = `<span class="warn">Пока временно приостановлено: очередь заполнена. После изменения коэффициента/лимита покупка снова заработает.</span>`;
return;
}
const amountLamports = solToLamports(document.getElementById("amountSol").value.trim());
const recipientRaw = document.getElementById("recipient").value.trim();
const recipient = recipientRaw ? new solanaWeb3.PublicKey(recipientRaw) : walletPubkey;
const nextIndex = queues.q1Total + 1n;
const [ticketPda] = solanaWeb3.PublicKey.findProgramAddressSync(
[utf8(SEEDS.ticketQ1), u64ToBytes(nextIndex)],
PROGRAM_ID
);
const disc = await ixDiscriminator("buy_ticket");
const data = concat(disc, u64ToBytes(amountLamports), recipient.toBytes());
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: pdas.configPda, isSigner: false, isWritable: true },
{ pubkey: pdas.coefPda, isSigner: false, isWritable: true },
{ pubkey: pdas.queuesPda, isSigner: false, isWritable: true },
{ pubkey: ticketPda, isSigner: false, isWritable: true },
{ pubkey: new solanaWeb3.PublicKey(config.dao), isSigner: false, isWritable: true },
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix);
out.innerHTML = `<span class="ok">Готово. Tx: <code>${sig}</code></span>`;
await refreshState();
} catch (e) {
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
document.getElementById("connectBtn").addEventListener("click", connectWallet);
document.getElementById("refreshBtn").addEventListener("click", refreshState);
document.getElementById("buyBtn").addEventListener("click", buyTicket);
refreshState();
</script>
</body>
</html>

View File

@ -0,0 +1,44 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Главная — Shine Payments Devnet</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; }
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
a.card {
display: block;
text-decoration: none;
color: inherit;
border: 1px solid #ddd;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
}
a.card:hover { background: #fafafa; }
.muted { color: #666; }
</style>
</head>
<body>
<h1>Shine Payments Devnet</h1>
<div class="panel">
<div>Выберите страницу:</div>
</div>
<a class="card" href="./buy_ticket.html">
<h3>Покупка билета</h3>
<div class="muted">Создание нового билета в 1-й очереди.</div>
</a>
<a class="card" href="./track_ticket.html">
<h3>Отслеживание билета</h3>
<div class="muted">Проверка позиции в очереди, статуса и шаг выплат.</div>
</a>
<a class="card" href="./admin_tools.html">
<h3>Тех. инструменты</h3>
<div class="muted">Init, просмотр всех билетов, коэффициент/лимит, адреса и балансы.</div>
</a>
</body>
</html>

View File

@ -0,0 +1,361 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Отслеживание билета — Shine Payments Devnet</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; }
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 8px; min-width: 260px; }
button { padding: 8px 12px; cursor: pointer; }
.muted { color: #666; }
.ok { color: #0a7a3c; }
.warn { color: #9f5f00; }
.err { color: #b30000; white-space: pre-wrap; }
.paid { color: #0a7a3c; font-weight: 700; }
.waiting { color: #666; }
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; }
</style>
</head>
<body>
<h1>Отслеживание билета (Devnet)</h1>
<div class="muted">Программа: <code id="programId"></code></div>
<div class="panel">
<div class="row">
<button id="connectBtn">Подключить кошелек</button>
<button id="refreshBtn">Обновить</button>
</div>
<div id="walletInfo" class="muted"></div>
</div>
<div class="panel">
<h3>Поиск билета</h3>
<div class="row">
<label>Номер билета: <input id="ticketIndex" placeholder="например 1" /></label>
<label>или кошелек получателя: <input id="recipientWallet" placeholder="Base58 адрес" /></label>
<button id="findBtn">Найти</button>
</div>
<div id="ticketResult" class="muted"></div>
</div>
<div class="panel">
<h3>Состояние шага выплат</h3>
<div id="payoutInfo" class="muted">Загрузка...</div>
<div class="row">
<button id="stepBtn">Сделать шаг выплат</button>
</div>
<div id="stepResult" class="muted"></div>
</div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
<script>
const PROGRAM_ID = new solanaWeb3.PublicKey("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE");
const RPC_URL = "https://api.devnet.solana.com";
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = {
config: "shine_payments_v2_config",
coef: "shine_payments_v2_coef_limit",
queues: "shine_payments_v2_queues",
ticketQ1: "shine_payments_v2_q1_ticket",
};
let walletPubkey = null;
let cachedCore = null;
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
function utf8(s) { return new TextEncoder().encode(s); }
function u64ToBytes(v) {
let x = BigInt(v);
const out = new Uint8Array(8);
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
return out;
}
function readU64(data, offset) {
let x = 0n;
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
return x;
}
function concat(...parts) {
const len = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(len);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
function trimZeros(v) {
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
}
function lamportsToSolStr(l) {
const sol = Number(l) / 1_000_000_000;
return trimZeros(sol.toFixed(9));
}
async function ixDiscriminator(name) {
const msg = utf8("global:" + name);
const hash = await crypto.subtle.digest("SHA-256", msg);
return new Uint8Array(hash).slice(0, 8);
}
function isNotEnoughForStep(msg) {
const s = String(msg || "").toLowerCase();
return s.includes("notenoughinflowforstep") || s.includes("0x177a");
}
function parseConfig(data) {
let o = 0;
const version = data[o++];
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const reward = readU64(data, o); o += 8;
return { version, dao, manager, inflow, reward };
}
function parseQueues(data) {
let o = 0;
const version = data[o++];
const q1Total = readU64(data, o); o += 8;
const q1Paid = readU64(data, o); o += 8;
const q1SumTotal = readU64(data, o); o += 8;
const q1SumPaid = readU64(data, o); o += 8;
const q2Total = readU64(data, o); o += 8;
const q2Paid = readU64(data, o); o += 8;
const q2SumTotal = readU64(data, o); o += 8;
const q2SumPaid = readU64(data, o); o += 8;
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
}
function parseTicket(data) {
let o = 0;
const version = data[o++];
const queueId = data[o++];
const index = readU64(data, o); o += 8;
const isPaid = data[o++] === 1;
const recipient = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const payout = readU64(data, o); o += 8;
const debtBefore = readU64(data, o); o += 8;
return { version, queueId, index, isPaid, recipient, payout, debtBefore };
}
function getProvider() {
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
return window.solana;
}
async function connectWallet() {
const provider = getProvider();
const r = await provider.connect();
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
await refreshAll();
}
async function sendInstruction(ix) {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
const tx = new solanaWeb3.Transaction().add(ix);
tx.feePayer = walletPubkey;
const bh = await connection.getLatestBlockhash("confirmed");
tx.recentBlockhash = bh.blockhash;
const signed = await provider.signTransaction(tx);
const sig = await connection.sendRawTransaction(signed.serialize(), { skipPreflight: false });
await connection.confirmTransaction({ signature: sig, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight }, "confirmed");
return sig;
}
function deriveCorePdas() {
const [configPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID);
const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
return { configPda, queuesPda };
}
function deriveQ1TicketPda(index) {
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(index)], PROGRAM_ID);
return pda;
}
async function loadCoreState() {
const pdas = deriveCorePdas();
const [cfgAi, qAi] = await Promise.all([
connection.getAccountInfo(pdas.configPda, "confirmed"),
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
]);
if (!cfgAi || !qAi) throw new Error("PDA не инициализированы. Запустите init на странице админки.");
const config = parseConfig(cfgAi.data);
const queues = parseQueues(qAi.data);
const inflowAi = await connection.getAccountInfo(config.inflow, "confirmed");
if (!inflowAi) throw new Error("Inflow vault отсутствует");
const rentMin = await connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed");
const available = BigInt(Math.max(0, inflowAi.lamports - rentMin));
cachedCore = { pdas, config, queues, inflowAi, available };
return cachedCore;
}
async function refreshPayoutInfo() {
const el = document.getElementById("payoutInfo");
try {
const core = await loadCoreState();
const q1Pending = core.queues.q1Total - core.queues.q1Paid;
const q2Pending = core.queues.q2Total - core.queues.q2Paid;
if (q1Pending === 0n && q2Pending === 0n) {
el.innerHTML = `
<div>Обе очереди пусты/полностью выплачены.</div>
<div>На inflow vault доступно (сверх ренты): <b>${lamportsToSolStr(core.available)} SOL</b></div>
<div class="warn">При шаге выплат эта сумма будет переведена в DAO, награда вызывающему не начисляется.</div>
`;
return;
}
if (q1Pending === 0n && q2Pending > 0n) {
el.innerHTML = `<div class="warn">Во 2-й очереди есть ожидание, но её выплаты пока не реализованы.</div>`;
return;
}
const nextIndex = core.queues.q1Paid + 1n;
const nextPda = deriveQ1TicketPda(nextIndex);
const nextAi = await connection.getAccountInfo(nextPda, "confirmed");
if (!nextAi) {
el.innerHTML = `<div class="err">Не найден следующий тикет #${nextIndex.toString()}</div>`;
return;
}
const next = parseTicket(nextAi.data);
const need = next.payout * 2n + core.config.reward;
const missing = core.available >= need ? 0n : (need - core.available);
el.innerHTML = `
<div>Следующий тикет: <b>#${next.index.toString()}</b></div>
<div>Выплата по тикету: <b>${lamportsToSolStr(next.payout)} SOL</b></div>
<div>Нужно для шага (выплата + DAO + награда): <b>${lamportsToSolStr(need)} SOL</b></div>
<div>Доступно в inflow vault: <b>${lamportsToSolStr(core.available)} SOL</b></div>
<div>${missing === 0n
? '<span class="ok">Хватает для шага выплаты.</span>'
: `<span class="warn">Не хватает: ${lamportsToSolStr(missing)} SOL</span>`
}</div>
`;
} catch (e) {
el.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
}
}
async function findTickets() {
const out = document.getElementById("ticketResult");
out.textContent = "";
try {
const core = await loadCoreState();
const idxRaw = document.getElementById("ticketIndex").value.trim();
const walletRaw = document.getElementById("recipientWallet").value.trim();
const results = [];
if (idxRaw) {
const idx = BigInt(idxRaw);
const pda = deriveQ1TicketPda(idx);
const ai = await connection.getAccountInfo(pda, "confirmed");
if (!ai) throw new Error(`Тикет #${idx.toString()} не найден`);
const t = parseTicket(ai.data);
results.push({ pda, t });
} else if (walletRaw) {
const recipient = new solanaWeb3.PublicKey(walletRaw);
for (let i = 1n; i <= core.queues.q1Total; i++) {
const pda = deriveQ1TicketPda(i);
const ai = await connection.getAccountInfo(pda, "confirmed");
if (!ai) continue;
const t = parseTicket(ai.data);
if (t.recipient.toBase58() === recipient.toBase58()) {
results.push({ pda, t });
}
}
if (results.length === 0) throw new Error("По этому кошельку тикеты не найдены");
} else {
throw new Error("Введите номер тикета или кошелек");
}
const nextIndex = core.queues.q1Paid + 1n;
const lines = results.map(({ pda, t }) => {
const isCurrent = !t.isPaid && t.index === nextIndex;
const inFront = t.index > nextIndex ? (t.index - nextIndex) : 0n;
const remainingToThis = t.debtBefore > core.queues.q1SumPaid ? (t.debtBefore - core.queues.q1SumPaid) : 0n;
const missingInsideCurrentTicket = isCurrent && core.available < t.payout ? (t.payout - core.available) : 0n;
return `
<div class="panel">
<div>Тикет #<b>${t.index.toString()}</b> (<span class="${t.isPaid ? "paid" : "waiting"}">${t.isPaid ? "выплачен" : "ожидание"}</span>)</div>
<div>PDA: <code>${pda.toBase58()}</code></div>
<div>Получатель: <code>${t.recipient.toBase58()}</code></div>
<div>Сумма выплаты: <b>${lamportsToSolStr(t.payout)} SOL</b></div>
<div>Билетов перед ним сейчас: <b>${inFront.toString()}</b></div>
<div>До его выплаты по сумме в предыдущих билетах осталось: <b>${lamportsToSolStr(remainingToThis)} SOL</b></div>
${isCurrent ? `<div class="warn">Это текущий билет к выплате.</div>` : ``}
${isCurrent && missingInsideCurrentTicket > 0n
? `<div class="warn">Для выплаты именно этого билета внутри его суммы не хватает: <b>${lamportsToSolStr(missingInsideCurrentTicket)} SOL</b>.</div>`
: ``}
</div>
`;
});
out.innerHTML = lines.join("");
} catch (e) {
out.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
}
}
async function stepPayout() {
const out = document.getElementById("stepResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) {
await connectWallet();
} else if (!provider.isConnected) {
await provider.connect();
}
const core = cachedCore || await loadCoreState();
const q1Pending = core.queues.q1Total - core.queues.q1Paid;
let nextTicketPda;
let recipient;
if (q1Pending > 0n) {
const nextIndex = core.queues.q1Paid + 1n;
nextTicketPda = deriveQ1TicketPda(nextIndex);
const ai = await connection.getAccountInfo(nextTicketPda, "confirmed");
if (!ai) throw new Error(`Следующий тикет #${nextIndex.toString()} не найден`);
recipient = parseTicket(ai.data).recipient;
} else {
nextTicketPda = deriveQ1TicketPda(core.queues.q1Paid + 1n);
recipient = walletPubkey;
}
const disc = await ixDiscriminator("step_payout");
const data = concat(disc);
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: core.pdas.configPda, isSigner: false, isWritable: true },
{ pubkey: core.pdas.queuesPda, isSigner: false, isWritable: true },
{ pubkey: core.config.inflow, isSigner: false, isWritable: true },
{ pubkey: nextTicketPda, isSigner: false, isWritable: true },
{ pubkey: recipient, isSigner: false, isWritable: true },
{ pubkey: core.config.dao, isSigner: false, isWritable: true },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix);
out.innerHTML = `<span class="ok">Шаг выполнен. Tx: <code>${sig}</code></span>`;
await refreshAll();
} catch (e) {
const raw = String(e.message || e);
if (isNotEnoughForStep(raw)) {
out.innerHTML = `<span class="warn">Недостаточно средств для шага выплаты. Это нормальная обработанная ошибка.</span>`;
return;
}
out.innerHTML = `<span class="err">${raw}</span>`;
}
}
async function refreshAll() {
await refreshPayoutInfo();
}
document.getElementById("connectBtn").addEventListener("click", connectWallet);
document.getElementById("refreshBtn").addEventListener("click", refreshAll);
document.getElementById("findBtn").addEventListener("click", findTickets);
document.getElementById("stepBtn").addEventListener("click", stepPayout);
refreshAll();
</script>
</body>
</html>

View File

@ -0,0 +1,33 @@
[package]
name = "shine_payments"
version = "0.1.0"
description = "Payments and investments smart contract"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
name = "shine_payments"
test = false
doctest = false
bench = false
[dependencies]
anchor-lang = "0.31.1"
common = { path = "../common" }
# ==== добавлено для NFT-функционала ====
anchor-spl = { version = "0.31.1", features = ["associated_token", "token"] }
mpl-token-metadata = "5.1.1"
spl-token = { version = "4.0.0", features = ["no-entrypoint"] }
# ======================================
[features]
default = []
no-entrypoint = []
no-idl = []
no-log-ix-name = []
anchor-debug = []
custom-heap = []
custom-panic = []
cpi = []
idl-build = ["anchor-lang/idl-build"]

View File

@ -0,0 +1,6 @@
# Важно
Эта папка содержит устаревшую версию `shine_payments`.
- Не использовать для новых доработок.
- Актуальная реализация находится в `programs/shine_payments`.

View File

@ -0,0 +1,158 @@
use anchor_lang::prelude::*;
declare_id!("qpgnAKhsXgPPaqQWfXhpme7UnG8GyStssuoSjF6Fzy3");
/// Подключаем модуль с полной реализацией.
pub mod investments;
use investments::*; // импортируем всё в корень
// === модуль NFT ===
pub mod nft;
// ==============================================
// Константы формата / сидов / размеров
// ==============================================
/// Префикс (seed) для PDA, где храним глобальное состояние выплат.
/// Важно: сид — это просто набор байт; здесь он фиксированный.
pub const PDA_SEED_PREFIX: &[u8] = b"shine_investments_state";
/// Значение коэффициента «по умолчанию» при инициализации.
pub const DEFAULT_COEF: u32 = 10; // ← «коэффициент» = 10 при init
/// Ровно столько байт резервируем под PDA-данные.
/// (Можно добавить запас на будущее, но по заданию — только 28.)
pub const PAY_STATE_SPACE: u64 = 50; // просто сделал с запасом
// ==============================================
// Программа
// ==============================================
#[program]
pub mod shine_payments {
use super::*;
// Явно подтягиваем типы и функции, чтобы не было путаницы после предыдущих ошибок парсера
use crate::investments::{Init, UseState};
use crate::investments::{
add_bonus as inv_add_bonus, claim as inv_claim, init as inv_init, invest as inv_invest,
ErrCode,
};
/// init — создаёт PDA и кладёт дефолтное состояние.
pub fn init(ctx: Context<Init>) -> Result<()> {
inv_init(ctx)
}
/// invest — в начале читает состояние, в конце сохраняет (логика внутри модуля).
pub fn invest(ctx: Context<UseState>, amount: u64) -> Result<()> {
inv_invest(ctx, amount)
}
/// add_bonus — начисление бонусов (обычно от DAO).
/// Для NFT используем расширенный контекст AddBonusCtx (с аккаунтами коллекции и т.п.).
pub fn add_bonus(ctx: Context<AddBonusCtx>, investor: Pubkey, amount: u64) -> Result<()> {
inv_add_bonus(ctx, investor, amount)
}
/// claim — выплата.
pub fn claim(ctx: Context<UseState>) -> Result<()> {
inv_claim(ctx)
}
/// ВРЕМЕННАЯ ФУНКЦИЯ только для тестов (в итоговой версии её не будет):
/// deleteInit — удалить PDA из init и вернуть ренту подписанту.
pub fn delete_init(ctx: Context<DeleteInit>) -> Result<()> {
let program_id = ctx.program_id;
// PDA по тем же сид/бамп, что и в init
let (expected_pda, _bump) = Pubkey::find_program_address(&[PDA_SEED_PREFIX], program_id);
require_keys_eq!(
expected_pda,
ctx.accounts.state_pda.key(),
ErrCode::InvalidPdaAddress
);
// Рента уйдёт на счёт подписанта (signer)
common::utils::delete_pda_return_rent(
&ctx.accounts.state_pda.to_account_info(),
&ctx.accounts.signer.to_account_info(),
program_id,
)
}
}
// ==============================================
// Контексты вне #[program]
// ==============================================
/// Контекст для deleteInit (временный для тестов)
#[derive(Accounts)]
pub struct DeleteInit<'info> {
/// Подписант транзакции — ПОЛУЧАТЕЛЬ ренты
#[account(mut)]
pub signer: Signer<'info>,
/// Тот самый PDA из init
/// CHECK: адрес валидируем в хендлере по сид-у
#[account(mut)]
pub state_pda: UncheckedAccount<'info>,
/// Системная программа
pub system_program: Program<'info, System>,
}
/// Контекст для add_bonus: полный набор аккаунтов для операций с NFT и коллекцией.
/// (Комменты по стилю проекта оставлены.)
#[derive(Accounts)]
pub struct AddBonusCtx<'info> {
/// Любой платящий/подписант (в реальном коде — свои проверки).
#[account(mut)]
pub signer: Signer<'info>,
/// Тот же PDA с состоянием (должен уже существовать).
/// CHECK: проверяется вручную по адресу
#[account(mut)]
pub state_pda: UncheckedAccount<'info>,
// --- аккаунты минтимого NFT ---
/// Mint создаваемого NFT (должен быть создан заранее: decimals=0, mint_authority=signer, freeze_authority=signer)
/// CHECK
#[account(mut)]
pub mint_pda: UncheckedAccount<'info>,
/// ATA получателя (может быть предсоздан тестом)
/// CHECK
#[account(mut)]
pub recipient_ata: UncheckedAccount<'info>,
/// Владелец ATA (инвестор)
/// CHECK
pub recipient_owner: UncheckedAccount<'info>,
// --- аккаунты коллекции (уже созданной заранее) ---
/// CHECK
pub collection_mint: UncheckedAccount<'info>,
/// CHECK
#[account(mut)]
pub collection_metadata_pda: UncheckedAccount<'info>,
/// CHECK
#[account(mut)]
pub collection_master_edition_pda: UncheckedAccount<'info>,
/// Апдейтер коллекции (update authority)
pub collection_update_authority: Signer<'info>,
// --- metadata + master edition для создаваемого NFT ---
/// CHECK
#[account(mut)]
pub metadata_pda: UncheckedAccount<'info>,
/// CHECK
#[account(mut)]
pub master_edition_pda: UncheckedAccount<'info>,
// --- программы ---
/// CHECK: проверяется по ID внутри nft.rs
pub token_metadata_program: UncheckedAccount<'info>,
pub token_program: Program<'info, anchor_spl::token::Token>,
pub associated_token_program: Program<'info, anchor_spl::associated_token::AssociatedToken>,
pub system_program: Program<'info, System>,
}

View File

@ -14,8 +14,6 @@ bench = false
[dependencies] [dependencies]
anchor-lang = "0.31.1" anchor-lang = "0.31.1"
common = { path = "../common" } common = { path = "../common" }
ed25519-dalek = { version = "1.0.1", default-features = false, features = ["u64_backend"] }
sha2 = "0.10"
[features] [features]

View File

@ -6,7 +6,7 @@ pub mod settings;
use users::*; use users::*;
declare_id!("5dFcWDNp42Xn9Vv4oDMJzM4obBJ8hvDuAtPX54fT5L3t"); declare_id!("8Z3HQizFRhyVu5cNBwWNBXZHTpu89VMkn7Wuk1oCtkeJ");
#[program] #[program]

View File

@ -1,9 +1,14 @@
use crate::settings; use crate::settings;
use anchor_lang::prelude::*; use anchor_lang::prelude::*;
use anchor_lang::solana_program::{program::invoke, system_instruction}; use anchor_lang::solana_program::{
ed25519_program,
hash::hashv,
instruction::Instruction,
program::invoke,
sysvar::instructions::{load_current_index_checked, load_instruction_at_checked},
system_instruction,
};
use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode}; use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode};
use ed25519_dalek::{PublicKey, Signature, Verifier};
use sha2::{Digest, Sha256};
use std::str::FromStr; use std::str::FromStr;
const MAGIC: &[u8; 5] = b"SHiNE"; const MAGIC: &[u8; 5] = b"SHiNE";
@ -78,6 +83,8 @@ pub struct CreateUserPda<'info> {
/// CHECK: адрес получателя комиссии проверяется вручную с константой settings. /// CHECK: адрес получателя комиссии проверяется вручную с константой settings.
#[account(mut)] #[account(mut)]
pub fee_receiver: AccountInfo<'info>, pub fee_receiver: AccountInfo<'info>,
/// CHECK: sysvar инструкций, нужен для проверки встроенной Ed25519Program инструкции.
pub instructions: AccountInfo<'info>,
} }
#[derive(Accounts)] #[derive(Accounts)]
@ -92,6 +99,8 @@ pub struct UpdateUserPda<'info> {
/// CHECK: адрес получателя комиссии проверяется вручную с константой settings. /// CHECK: адрес получателя комиссии проверяется вручную с константой settings.
#[account(mut)] #[account(mut)]
pub fee_receiver: AccountInfo<'info>, pub fee_receiver: AccountInfo<'info>,
/// CHECK: sysvar инструкций, нужен для проверки встроенной Ed25519Program инструкции.
pub instructions: AccountInfo<'info>,
} }
pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) -> Result<()> { pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) -> Result<()> {
@ -138,8 +147,12 @@ pub fn create_user_pda(ctx: Context<CreateUserPda>, args: CreateUserPdaArgs) ->
}; };
let unsigned = serialize_unsigned_record(&record)?; let unsigned = serialize_unsigned_record(&record)?;
verify_record_signature(&record.root_key, &args.signature, &unsigned)?; record.signature = verify_record_signature(
record.signature = vec_to_signature(&args.signature)?; &ctx.accounts.instructions,
&record.root_key,
&args.signature,
&unsigned,
)?;
let serialized = serialize_full_record(&record)?; let serialized = serialize_full_record(&record)?;
require!( require!(
@ -247,8 +260,12 @@ pub fn update_user_pda(ctx: Context<UpdateUserPda>, args: UpdateUserPdaArgs) ->
}; };
let unsigned = serialize_unsigned_record(&new_record)?; let unsigned = serialize_unsigned_record(&new_record)?;
verify_record_signature(&new_record.root_key, &args.signature, &unsigned)?; new_record.signature = verify_record_signature(
new_record.signature = vec_to_signature(&args.signature)?; &ctx.accounts.instructions,
&new_record.root_key,
&args.signature,
&unsigned,
)?;
let serialized = serialize_full_record(&new_record)?; let serialized = serialize_full_record(&new_record)?;
require!( require!(
@ -418,22 +435,110 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord> {
fn hash_unsigned_record(record: &UserRecord) -> Result<[u8; 32]> { fn hash_unsigned_record(record: &UserRecord) -> Result<[u8; 32]> {
let unsigned = serialize_unsigned_record(record)?; let unsigned = serialize_unsigned_record(record)?;
let digest = Sha256::digest(unsigned); let digest = hashv(&[&unsigned]);
let mut out = [0u8; 32]; let mut out = [0u8; 32];
out.copy_from_slice(&digest); out.copy_from_slice(digest.as_ref());
Ok(out) Ok(out)
} }
fn verify_record_signature(root_key: &Pubkey, signature: &[u8], unsigned: &[u8]) -> Result<()> { fn verify_record_signature(
let sig_arr = vec_to_signature(signature)?; instructions_sysvar: &AccountInfo,
let hash = Sha256::digest(unsigned); root_key: &Pubkey,
let verify_key = signature: &[u8],
PublicKey::from_bytes(root_key.as_ref()).map_err(|_| error!(ErrCode::InvalidSignature))?; unsigned: &[u8],
let sig = Signature::from_bytes(&sig_arr).map_err(|_| error!(ErrCode::InvalidSignature))?; ) -> Result<[u8; 64]> {
verify_key require_keys_eq!(
.verify(hash.as_slice(), &sig) *instructions_sysvar.key,
anchor_lang::solana_program::sysvar::instructions::id(),
ErrCode::InvalidSignature
);
let provided_sig = vec_to_signature(signature)?;
let msg_hash = hashv(&[unsigned]);
let current_ix_index =
load_current_index_checked(instructions_sysvar).map_err(|_| error!(ErrCode::InvalidSignature))?;
require!(current_ix_index > 0, ErrCode::InvalidSignature);
let ed_ix = load_instruction_at_checked(
(current_ix_index - 1) as usize,
instructions_sysvar,
)
.map_err(|_| error!(ErrCode::InvalidSignature))?; .map_err(|_| error!(ErrCode::InvalidSignature))?;
Ok(())
let parsed = parse_ed25519_ix(&ed_ix)?;
require_keys_eq!(parsed.pubkey, *root_key, ErrCode::InvalidSignature);
require!(parsed.message == msg_hash.as_ref(), ErrCode::InvalidSignature);
require!(parsed.signature == provided_sig, ErrCode::InvalidSignature);
Ok(parsed.signature)
}
struct ParsedEd25519 {
pub pubkey: Pubkey,
pub signature: [u8; 64],
pub message: Vec<u8>,
}
fn parse_ed25519_ix(ix: &Instruction) -> Result<ParsedEd25519> {
require_keys_eq!(ix.program_id, ed25519_program::id(), ErrCode::InvalidSignature);
let data = &ix.data;
require!(data.len() >= 16, ErrCode::InvalidSignature);
require!(data[0] == 1, ErrCode::InvalidSignature); // одна подпись
let signature_offset = le_u16(data, 2)? as usize;
let signature_ix_index = le_u16(data, 4)?;
let pubkey_offset = le_u16(data, 6)? as usize;
let pubkey_ix_index = le_u16(data, 8)?;
let message_offset = le_u16(data, 10)? as usize;
let message_size = le_u16(data, 12)? as usize;
let message_ix_index = le_u16(data, 14)?;
require!(signature_ix_index == u16::MAX, ErrCode::InvalidSignature);
require!(pubkey_ix_index == u16::MAX, ErrCode::InvalidSignature);
require!(message_ix_index == u16::MAX, ErrCode::InvalidSignature);
let signature_end = signature_offset
.checked_add(64)
.ok_or(error!(ErrCode::InvalidSignature))?;
let pubkey_end = pubkey_offset
.checked_add(32)
.ok_or(error!(ErrCode::InvalidSignature))?;
let message_end = message_offset
.checked_add(message_size)
.ok_or(error!(ErrCode::InvalidSignature))?;
let signature_slice = data
.get(signature_offset..signature_end)
.ok_or(error!(ErrCode::InvalidSignature))?;
let pubkey_slice = data
.get(pubkey_offset..pubkey_end)
.ok_or(error!(ErrCode::InvalidSignature))?;
let message = data
.get(message_offset..message_end)
.ok_or(error!(ErrCode::InvalidSignature))?
.to_vec();
let mut signature = [0u8; 64];
signature.copy_from_slice(signature_slice);
let pubkey = Pubkey::new_from_array(
<[u8; 32]>::try_from(pubkey_slice).map_err(|_| error!(ErrCode::InvalidSignature))?,
);
Ok(ParsedEd25519 {
pubkey,
signature,
message,
})
}
fn le_u16(data: &[u8], offset: usize) -> Result<u16> {
let end = offset
.checked_add(2)
.ok_or(error!(ErrCode::InvalidSignature))?;
let s = data
.get(offset..end)
.ok_or(error!(ErrCode::InvalidSignature))?;
Ok(u16::from_le_bytes([s[0], s[1]]))
} }
fn validate_login(login: &str) -> Result<()> { fn validate_login(login: &str) -> Result<()> {

View File

@ -0,0 +1,55 @@
# Ключи и деплой (тестовое пояснение)
## 1) Какие адреса участвуют
В проекте есть **2 программы**, поэтому у них **2 разных Program ID**:
1. `shine_users` -> отдельный адрес программы
2. `shine_payments` -> отдельный адрес программы
Это нормальная схема Solana: одна программа = один Program ID.
Отдельно есть адрес кошелька-деплоера (upgrade authority), сейчас это:
- keypair: `~/.config/solana/id.json`
- адрес: `4yzHKs2zFXpyqqCETe8KpAs4xhEo4QhJ2ybyTgRZphZv`
Именно этот кошелек:
- платит комиссии/ренту при деплое;
- владеет правом апгрейда программ;
- получает обратно SOL при `solana program close`.
## 2) Почему раньше "плавали" адреса программ
`anchor deploy` берет адрес программы из program keypair файла (`target/deploy/*-keypair.json`).
Если keypair другой, Program ID тоже будет другой.
Чтобы этого не было, нужно держать синхронно:
1. `declare_id!` в `programs/*/src/lib.rs`
2. `[programs.devnet]` и `[programs.localnet]` в `Anchor.toml`
3. соответствующие `*-keypair.json` для программ
Сделано:
- выполнен `anchor keys sync`;
- keypair CLI по умолчанию переключен на `~/.config/solana/id.json`;
- сохранены копии program keypair в `shine/keys/`.
## 3) Сколько SOL занимали программы раньше (до закрытия)
Перед очисткой были закрыты 4 программы с такими возвратами:
1. `8Z3HQizFRhyVu5cNBwWNBXZHTpu89VMkn7Wuk1oCtkeJ` -> `3.38059032 SOL`
2. `qpgnAKhsXgPPaqQWfXhpme7UnG8GyStssuoSjF6Fzy3` -> `2.11208856 SOL`
3. `5dFcWDNp42Xn9Vv4oDMJzM4obBJ8hvDuAtPX54fT5L3t` -> `1.76425560 SOL`
4. `92sgkgx7KHpbhQu81mNGHaKa7skJB7esArVdPM7paDSW` -> `1.66820760 SOL`
Итого было занято программами:
- `8.92514208 SOL`
Из них "актуальная пара" (2 программы последнего деплоя) занимала:
- `3.38059032 + 2.11208856 = 5.49267888 SOL`