commit 3032a098860bad015b7ddc9531f6165a3fcaf7a3 Author: armaniferrante Date: Thu Mar 11 07:31:15 2021 -0800 Init repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a0ece2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.anchor +target diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0b5dd4d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,53 @@ +dist: bionic +language: rust +rust: + - nightly +cache: cargo +env: + - NODE_VERSION="14.7.0" + +before_deploy: + - anchor build --verifiable + - echo "### SHA256 Checksums" > release_notes.md + - sha256sum target/deploy/lockup.so > lockup_binary.txt + - sha256sum target/idl/lockup.json > lockup_idl.txt + - sha256sum target/deploy/registry.so > registry_binary.txt + - sha256sum target/idl/registry.json > registry_idl.txt + - cat *.txt >> release_notes.md + +deploy: + provider: releases + edge: true + file: + - "target/deploy/lockup.so" + - "target/deploy/registry.so" + - "target/idl/lockup.json" + - "target/idl/registry.json" + release_notes_file: release_notes.md + skip_cleanup: true + on: + tags: true + api_key: + secure: MRahuKj/FhxUwkkvqiI3wJYWKzJ0PVl25ZfFhp5lA7xyYYj/heQOdX1rE8I3MkyBOWlSNAN89JXKQ61czOrkpjK/vjBt7/49iCkWuBd+ZQ0SOjrdFubAMl4ypd3C56v28Q/Rh5bAgm8IiJNeCidfWjiu36ibjAHMAxkwAssp76AV0hboWMJx6i4i8W/iFC8hQhiFa4npkTkrCtL4Vt8qY0fwqNRRpMZBIz22ZglYbhWpkaPMeikFun7Fjn9dvT0PM/xtcjTYOf4sxdjItpYjR0fUF+thuR+z4McgeYko3AZG9Sv8RMvw6yU1Hpq/Okk1wXcxNHyDtz/YriwiPgVzcIW2SGW2YxXh8YZEQFJuVodM8udYjFuNHy+qcNDiCvcoNIj2zYP3iWEVpiv4a3Hr33T/+iGTqLkjlnHcLKI8m2ykbHFtmNEmg6P4faayYkDSeEKRMZSDuA+CKh07LVlBQFyIRB3tfw3+tdBGQXQojgxAwuxnfANhScMpSdjZtdJCS912ijzVeSGa6C33+/fpAqQtCQqwJx+Bl6Bytvq+nBSjojWJZUqvE53IFwD5/bSd6FUIyAQMnQ6t8dOF+OWx0a1rFtpfLYYKZwei8kZlWNd4BLs+V0jkyHTzy0Cztre/EcmXdAHxAX8XrcrWt9sC1gwpApdrtZ20zZhEHsdgY/k= + +_defaults: &defaults + before_install: + - nvm install $NODE_VERSION + - npm install -g mocha + - npm install -g @project-serum/anchor + - npm install -g @project-serum/serum + - npm install -g @project-serum/common + - npm install -g @solana/spl-token + - sudo apt-get install -y pkg-config build-essential libudev-dev + - sh -c "$(curl -sSfL https://release.solana.com/v1.5.5/install)" + - export PATH="/home/travis/.local/share/solana/install/active_release/bin:$PATH" + - export NODE_PATH="/home/travis/.nvm/versions/node/v$NODE_VERSION/lib/node_modules/:$NODE_PATH" + - yes | solana-keygen new + - cargo install --git https://github.com/project-serum/anchor anchor-cli --locked + +jobs: + include: + - <<: *defaults + name: Runs the tests + script: + - anchor test diff --git a/Anchor.toml b/Anchor.toml new file mode 100644 index 0000000..2ebd5af --- /dev/null +++ b/Anchor.toml @@ -0,0 +1,2 @@ +cluster = "localnet" +wallet = "~/.config/solana/id.json" diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..524e642 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1006 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "ahash" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "739f4a8db6605981345c5654f3a85b056ce52f37a39d34da03f25bf2151ea16e" + +[[package]] +name = "aho-corasick" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5" +dependencies = [ + "memchr", +] + +[[package]] +name = "anchor-attribute-access-control" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476873adeb35a1b53d49ceda8fbe913a6601a3379552b9399dd38c4dbf8f755c" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "anchor-attribute-account" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b65f521e0b4ac27b4df15f5dd65c8c468887debc62a2e553fbb34b3172d09a0" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "anchor-attribute-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f778a066327b8ae905a31cdf80fbc7476a0601c745f08e7010c4e1728125f6f" +dependencies = [ + "anchor-syn", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "anchor-attribute-interface" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200f17ff304a8b501194f1028ee6bbcb88c1c820f47a4f5e589cb24458da4bcf" +dependencies = [ + "anchor-syn", + "anyhow", + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "anchor-attribute-program" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84459fc50f5c9c2b39226c0f06a831b4f341046ec27f5e87449f536db372116f" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "anchor-attribute-state" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7857e7b9cf52321653ec89b5ab0486d189953128274e4be2d131e70f274e85c5" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "anchor-derive-accounts" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3d3d0bf656d6606be906418c671540e79748de28787017ef2e1f88af452544" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "anchor-lang" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d32e5b3b27593f5f2de3d980423c3ec8aef1350fbc5052b671001811743575f" +dependencies = [ + "anchor-attribute-access-control", + "anchor-attribute-account", + "anchor-attribute-error", + "anchor-attribute-interface", + "anchor-attribute-program", + "anchor-attribute-state", + "anchor-derive-accounts", + "serum-borsh", + "solana-program", + "thiserror", +] + +[[package]] +name = "anchor-spl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffff655dabfb01706289c2d31f89ba670a6a6f01d2c01de4fcfceff9a3f98a49" +dependencies = [ + "anchor-lang", + "solana-program", + "spl-token", +] + +[[package]] +name = "anchor-syn" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c40b707f33392ee103f2255b392a703204f6c9c07652ba689abff3a35cdec8e" +dependencies = [ + "anyhow", + "bs58", + "heck", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.9.3", + "syn", + "thiserror", +] + +[[package]] +name = "anyhow" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afddf7f520a80dbf76e6f50a35bca42a2331ef227a28b3b6dc5c2e2338d114b1" + +[[package]] +name = "arrayref" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "bincode" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d175dfa69e619905c4c3cdb7c3c203fa3bdd5d51184e3afdb2742c0280493772" +dependencies = [ + "byteorder", + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" +dependencies = [ + "block-padding", + "byte-tools", + "byteorder", + "generic-array 0.12.4", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array 0.14.4", +] + +[[package]] +name = "block-padding" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" +dependencies = [ + "byte-tools", +] + +[[package]] +name = "borsh-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "307f3740906bac2c118a8122fe22681232b244f1369273e45f1156b45c43d2dd" +dependencies = [ + "borsh-derive-internal", + "borsh-schema-derive-internal", + "proc-macro-crate", + "proc-macro2", + "syn", +] + +[[package]] +name = "borsh-derive-internal" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2104c73179359431cc98e016998f2f23bc7a05bc53e79741bcba705f30047bc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "borsh-schema-derive-internal" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae29eb8418fcd46f723f8691a2ac06857d31179d33d2f2d91eb13967de97c728" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bs58" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476e9cd489f9e121e02ffa6014a8ef220ecb15c05ed23fc34cca13925dc283fb" + +[[package]] +name = "bv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8834bb1d8ee5dc048ee3124f2c7c1afcc6bc9aed03f11e9dfd8c69470a5db340" +dependencies = [ + "feature-probe", + "serde", +] + +[[package]] +name = "byte-tools" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" + +[[package]] +name = "byteorder" +version = "1.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cpuid-bool" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aebca1129a03dc6dc2b127edd729435bbc4a37e1d5f4d7513165089ceb02634" + +[[package]] +name = "curve25519-dalek" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434e1720189a637d44fe464f4df1e6eb900b4835255b14354497c78af37d9bb8" +dependencies = [ + "byteorder", + "digest 0.8.1", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +dependencies = [ + "generic-array 0.12.4", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array 0.14.4", +] + +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + +[[package]] +name = "env_logger" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17392a012ea30ef05a610aa97dfb49496e71c9f676b27879922ea5bdf60d9d3f" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "fake-simd" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" + +[[package]] +name = "feature-probe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da" + +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" +dependencies = [ + "serde", + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +dependencies = [ + "ahash", +] + +[[package]] +name = "heck" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "itertools" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b07a082330a35e43f63177cc01689da34fbffa0105e1246cf0311472cac73a" + +[[package]] +name = "lockup" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", +] + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" + +[[package]] +name = "memmap2" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b70ca2a6103ac8b665dc150b142ef0e4e89df640c9e6cf295d189c3caebe5a" +dependencies = [ + "libc", +] + +[[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", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "226b45a5c2ac4dd696ed30fa6b94b057ad909c7b7fc2e0d0808192bced894066" +dependencies = [ + "derivative", + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c0fd9eba1d5db0994a239e09c1be402d35622277e35468ba891aa5e3188ce7e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "once_cell" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" + +[[package]] +name = "opaque-debug" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "ppv-lite86" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" + +[[package]] +name = "proc-macro-crate" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" +dependencies = [ + "toml", +] + +[[package]] +name = "proc-macro2" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom", + "libc", + "rand_chacha", + "rand_core", + "rand_hc", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9251239e129e16308e70d853559389de218ac275b515068abc96829d05b948a" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", + "thread_local", +] + +[[package]] +name = "regex-syntax" +version = "0.6.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eb417147ba9860a96cfe72a0b93bf88fee1744b5636ec99ab20c1aa9376581" + +[[package]] +name = "registry" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "lockup", +] + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5d2a036dc6d2d8fd16fde3498b04306e29bd193bf306a57427019b823d5acd" + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "serde" +version = "1.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06c64263859d87aa2eb554587e2d23183398d617427327cf2b3d0ed8c69e4800" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ae07dd2f88a366f15bd0632ba725227018c69a1c8550a927324f8eb8368bb9" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c84d3526699cd55261af4b941e4e725444df67aa4f9e6a3564f18030d12672df" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serum-borsh" +version = "0.8.1-serum.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4fed3f601b23f15dc890f6e52ffdbfe2dcf16418a41e0aa016b5f10cf30c892" +dependencies = [ + "borsh-derive", + "hashbrown", + "solana-program", +] + +[[package]] +name = "sha2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a256f46ea78a0c0d9ff00077504903ac881a1dafdc20da66545699e7776b3e69" +dependencies = [ + "block-buffer 0.7.3", + "digest 0.8.1", + "fake-simd", + "opaque-debug 0.2.3", +] + +[[package]] +name = "sha2" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa827a14b29ab7f44778d14a88d3cb76e949c45083f7dbfa507d0cb699dc12de" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpuid-bool", + "digest 0.9.0", + "opaque-debug 0.3.0", +] + +[[package]] +name = "solana-frozen-abi" +version = "1.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3476d3735fcbd907c86095cf4de0272a11753354bfc8484556173d4a9b458e" +dependencies = [ + "bs58", + "bv", + "generic-array 0.14.4", + "log", + "memmap2", + "rustc_version", + "serde", + "serde_derive", + "sha2 0.9.3", + "solana-frozen-abi-macro", + "solana-logger", + "thiserror", +] + +[[package]] +name = "solana-frozen-abi-macro" +version = "1.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33137fde347f368f3a44992544a799e9cd64276d391b9e7339468512e919ffac" +dependencies = [ + "lazy_static", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "solana-logger" +version = "1.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70991babdd9869469fbe29119958ce5a26bad54418ed3248ce172807e112f106" +dependencies = [ + "env_logger", + "lazy_static", + "log", +] + +[[package]] +name = "solana-program" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7df63d84d4ba7f67365d179b9994f2690554f313e219f29810b4583077e066" +dependencies = [ + "bincode", + "bs58", + "bv", + "curve25519-dalek", + "hex", + "itertools", + "lazy_static", + "log", + "num-derive", + "num-traits", + "rand", + "rustc_version", + "rustversion", + "serde", + "serde_bytes", + "serde_derive", + "sha2 0.8.2", + "solana-frozen-abi", + "solana-frozen-abi-macro", + "solana-logger", + "solana-sdk-macro", + "thiserror", +] + +[[package]] +name = "solana-sdk-macro" +version = "1.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41c78a955eb8c4b5cd72c2450520fc9434b1953f9398f91d13a89d5db841505" +dependencies = [ + "bs58", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "spl-token" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9774eebb62ff1ff2f5eca112413e476143925a2f5a43cee98fc5d3a6c0eec5c" +dependencies = [ + "arrayref", + "num-derive", + "num-traits", + "num_enum", + "solana-program", + "thiserror", +] + +[[package]] +name = "subtle" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2" + +[[package]] +name = "syn" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4211ce9909eb971f111059df92c45640aad50a619cf55cd76476be803c4c68e6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "termcolor" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0f4a65597094d4483ddaed134f409b2cb7c1beccf25201a9f73c719254fa98e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7765189610d8241a44529806d6fd1f2e0a08734313a35d5b3a556f92b381f3c0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd" +dependencies = [ + "once_cell", +] + +[[package]] +name = "toml" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" +dependencies = [ + "serde", +] + +[[package]] +name = "typenum" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373c8a200f9e67a0c95e62a4f52fbf80c23b4381c05a17845531982fa99e6b33" + +[[package]] +name = "unicode-segmentation" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" + +[[package]] +name = "unicode-xid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" + +[[package]] +name = "version_check" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "zeroize" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81a974bcdd357f0dca4d41677db03436324d45a4c9ed2d0b873a5a360ce41c36" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a60de98 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,4 @@ +[workspace] +members = [ + "programs/*" +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7b25ee --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Stake + +Programs for staking and lockups. For a technical introduction, see the [docs](./docs). + +## Note + +* **This code is unaudited. Use at your own risk.** + +## Developing + +[Anchor](https://github.com/project-serum/anchor) is used for developoment, and it's +recommended workflow is used here. To get started, see the [guide](https://project-serum.github.io/anchor/getting-started/introduction.html). + +### Build + +``` +anchor build --verifiable +``` + +The `--verifiable` flag should be used before deploying so that your build artifacts +can be deterministically generated with docker. + +### Test + +``` +anchor test +``` + +### Verify + +To verify the program deployed on Solana matches your local source code, change directory +into the program you want to verify, e.g., `cd programs/registry`, and run + +```bash +anchor verify +``` + +A list of build artifacts can be found under [releases](https://github.com/project-serum/stake/releases). diff --git a/docs/lockups.md b/docs/lockups.md new file mode 100644 index 0000000..8cd44a2 --- /dev/null +++ b/docs/lockups.md @@ -0,0 +1,127 @@ +# Lockups + +WARNING: All code related to Lockups is unaudited. Use at your own risk. + +## Introduction + +The **Lockup** program provides a simple mechanism to lockup tokens +of any mint, and release those funds over time as defined by a vesting schedule. +Although these lockups track a target **beneficiary**, who will eventually receive the +funds upon vesting, a proper deployment of the program will ensure this **beneficiary** +can never actually retrieve tokens before vesting. Funds are *never* in an SPL +token wallet owned by a user, and are completely program controlled. + +## Accounts + +There is a single account type used by the program. + +* `Vesting` - An account defining a vesting schedule, realization condition, and vault holding the tokens to be released over time. + +## Creating a Vesting Account + +Lockup occurs when tokens are transferred into the program creating a **Vesting** +account on behalf of a **beneficiary** via the `CreateVesting` instruction. +There are three parameters to specify: + +* Start timestamp - unix timestamp (in seconds) of the time when vesting begins. +* End timestamp - unix timestamp (in seconds) of the time when all tokens will unlock. +* Period count - the amount of times vesting should occur. +* Deposit amount - the total amount to vest. +* Realizer - the program defining if and when vested tokens can be distributed to a beneficiary. + +Together these parameters form a linearly unlocked vesting schedule. For example, +if one wanted to lock 100 SPL tokens that unlocked twice, 50 each time, over the next year, one +would use the following parameters (in JavaScript). + +```javascript +const startTimestamp = Date.now()/1000; +const endTimestamp = Date.now()/1000 + 60*60*24*365; +const periodCount = 2; +const depositAmount = 100 * 10**6; // 6 decimal places. +const realizer = null; // No realizer in this example. +``` + +From these five parameters, one can deduce the total amount vested at any given time. + +Once created, a **Vesting** account's schedule cannot be mutated. + +## Withdrawing from a Vesting Account + +Withdrawing is straightforward. Simply invoke the `Withdraw` instruction, specifying an +amount to withdraw from a **Vesting** account. The **beneficiary** of the +**Vesting** account must sign the transaction, but if enough time has passed for an +amount to be vested, and, if the funds are indeed held in the lockup program's vault +(a point mentioned below) then the program will release the funds. + +## Realizing Locked Tokens + +Optionally, vesting accounts can be created with a `realizer` program, which is +a program implementing the lockup program's `RealizeLock` trait. In +addition to the vesting schedule, a `realizer` program determines if and when a +beneficiary can ever seize control over locked funds. It's effectively a function +returning a boolean: is realized or not. + +The uses cases for a realizer are application specific. +For example, in the case of the staking program, when a vesting account is distributed as a reward, +the staking program sets itself as the realizor, ensuring that the only way for the vesting account +to be realized is if the beneficiary completely unstakes and incurs the unbonding timelock alongside +any other consequences of unstaking (e.g., the inability to vote on governance proposals). +This implies that, if one never unstakes, one never receives locked token rewards, adding +an additional consideration when managing one's stake. + +If no such `realizer` exists, tokens are realized upon account creation. + +## Whitelisted Programs + +Although funds cannot be freely withdrawn prior to vesting, they can be sent to/from +other programs that are part of a **Whitelist**. These programs are completely trusted. +Any bug or flaw in the design of a whitelisted program can lead to locked tokens being released +ahead of schedule, so it's important to take great care when whitelisting any program. + +This of course begs the question, who approves the whitelist? The **Lockup** program doesn't +care. There simply exists an **authority** key that can, for example, be a democratic multisig, +a single admin, or the zero address--in which case the authority ceases to exist, as the +program will reject transactions signing from that address. Although the **authority** can never +move a **Vesting** account's funds, whoever controls the **authority** key +controls the whitelist. So when using the **Lockup** program, one should always be +cognizant of it's whitelist governance, which ultimately anchors one's trust in the program, +if any at all. + +## Creating a Whitelisted Program + +To create a whitelisted program that receives withdrawals/deposits from/to the Lockup program, +one needs to implement the whitelist transfer interface, which assumes nothing about the +`instruction_data` but requires accounts to be provided in a specific [order](https://github.com/project-serum/serum-dex/blob/master/registry/program/src/deposit.rs#L18). + +Take staking locked tokens as a working example. + +### Staking Locked Tokens + +Suppose you have a vesting account with some funds you want to stake. + +First, one must add the staking **Registry** as a whitelisted program, so that the Lockup program +allows the movement of funds. This is done by the `WhitelistAdd` instruction. + +Once whitelisted, **Vesting** accounts can transfer funds out of the **Lockup** program and +into the **Registry** program by invoking the **Lockup** program's `WhitelistWithdraw` +instruction, which, other than access control, simply relays the instruction from the +**Lockup** program to the **Registry** program along with accounts, signing the +Cross-Program-Invocation (CPI) with the **Lockup**'s program-derived-address to allow +the transfer of funds, which ultimately is done by the **Registry**. *It is the Registry's responsibility +to track where these funds came from, keep them locked, and eventually send them back.* + +When creating this instruction on the client, there are two parameters to provide: +the maximum `amount` available for transfer and the opaque CPI `instruction_data`. +In the example, here, it would be the Borsh serialized instruction data for the +**Registry**'s `Deposit` instruction. + +The other direction follows, similarly. One invokes the `WhitelistDeposit` instruction +on the **LockupProgram**, relaying the transaction to the **Registry**, which ultimately +transfer funds back into the lockup program on behalf of the **Vesting** account. + +## Major version upgrades. + +Assuming the `authority` account is set on the **Lockup** program, one can use this Whitelist +mechanism to do major version upgrades of the lockup program. One can whitelist the +new **Lockup** program, and then all **Vesting** accounts would invidiually perform the migration +by transferring their funds to the new proigram via the `WhitelistWithdraw` instruction. diff --git a/docs/staking.md b/docs/staking.md new file mode 100644 index 0000000..ad7c841 --- /dev/null +++ b/docs/staking.md @@ -0,0 +1,193 @@ +# Staking + +WARNING: All code related to staking is unaudited. Use at your own risk. + +## Introduction + +The **Registry** program provides an on-chain mechanism for a group of stakers to + +* Share rewards proprtionally amongst a staking pool +* Govern on chain protocols with stake weighted voting +* Stake and earn locked tokens + +The program makes little assumptions about the form of stake or rewards. +In the same way you can make a new SPL token with its own mint, you can create a new stake +pool. Although the token being staked must be a predefined mint upon pool initialization, +rewards on a particular pool can be arbitrary SPL tokens, or, in the case of locked rewards, +program controlled accounts. +Rewards can come from an arbitrary +wallet, e.g. automatically from a fee earning program, +or manually from a wallet owned by an individual. The specifics are token and protocol +dependent. + +Similarly, the specifics of governance are not assumed by the staking program. However, a +governance system can use this program as a primitive to implement stake weighted voting. + +Here staking is covered at somewhat of a low level with the goal of allowing one +to understand, contribute to, or modify the code. + +## Accounts + +Accounts are the pieces of state owned by a Solana program. For reference while reading, here are all +accounts used by the **Registry** program. + +* `Registrar` - Analagous to an SPL token `Mint`, the `Registrar` defines a staking instance. It has its own pool, and it's own set of rewards distributed amongst its own set of stakers. +* `Member` - Analogous to an SPL token `Account`, `Member` accounts represent a **beneficiary**'s (i.e. a wallet's) stake state. This account has several vaults, all of which represent the funds belonging to an individual user. +* `PendingWithdrawal` - A transfer out of the staking pool (poorly named since it's not a withdrawal out of the program. But a withdrawal out of the staking pool and into a `Member`'s freely available balances). +* `RewardVendor` - A reward that has been dropped onto stakers and is distributed pro rata to staked `Member` beneficiaries. +* `RewardEventQueue` - A ring buffer of all rewards available to stakers. Each entry is the address of a `RewardVendor`. + +## Creating a member account. + +Before being able to enter the stake pool, one must create a **Member** account with the +**Registrar**, providing identity to the **Registry** program. By default, each member has +four types of token vaults making up a set of balances owned by the program on behalf of a +**Member**: + +* Available balances: a zero-interest earning token account with no restrictions. +* Pending: unstaked funds incurring an unbonding timelock. +* Stake: the total amount of tokens staked. +* Stake pool token: the total amount of pool tokens created from staking (`stake = stake-pool-token * stake-pool-token-price`). + +Each of these vaults provide a unit of balance isolation unique to a **Member**. +That is, although the stake program appears to provide a pooling mechanism, funds between +**Member** accounts are not commingled. They do not share SPL token accounts, and the only +way for funds to move is for a **Member**'s beneficiary to authorize instructions that either exit the +system or move funds between a **Member**'s own vaults. + +## Depositing and Withdrawing. + +Funds initially enter and exit the program through the `Deposit` and `Withdraw` instructions, +which transfer funds into and out of the **available balances** vault. +As the name suggests, all funds in this vault are freely available, unrestricted, and +earn zero interest. The vault is purely a gateway for funds to enter the program. + +## Staking. + +Once deposited, a **Member** beneficiary invokes the `Stake` instruction to transfer funds from +their **available-balances-vault** to one's **stake-vault**, creating newly minted +**stake-pool-tokens** as proof of the stake deposit. These new tokens represent +one's proportional right to all rewards distributed to the staking pool and are offered +by the **Registry** program at a fixed price, e.g., of 500 SPL tokens. + +## Unstaking + +Once staked, funds cannot be immediately withdrawn. Rather, the **Registrar** will enforce +a one week timelock before funds are released. Upon executing the `StartUnstake` +instruction, three operations execute. 1) The given amount of stake pool tokens will be burned. +2) Staked funds proportional to the stake pool tokens burned will be transferred from the +**Member**'s **stake-vault** to the **Member**'s **pending-vault**. 3) A `PendingWithdrawal` +account will be created as proof of the stake withdrawal, stamping the current block's +`unix_timestamp` onto the account. When the timelock period ends, a **Member** can invoke the +`EndUnstake` instruction to complete the transfer out of the `pending-vault` and +into the `available-balances`, providing the previously printed `PendingWithdrawal` +receipt to the program as proof that the timelock has passed. At this point, the exit +from the stake pool is complete, and the funds are ready to be used again. + +## Reward Design Motivation + +Feel free to skip this section and jump to the **Reward Vendors** section if you want to +just see how rewards work. + +One could imagine several ways to drop rewards onto a staking pool, each with their own downsides. +Of course what you want is, for a given reward amount, to atomically snapshot the state +of the staking pool and to distribute it proportionally to all stake holders. Effectively, +an on chain program such as + +```python +for account in stake_pool: + account.token_amount += total_reward * (account.stake_pool_token.amount / stake_pool_token.supply) + ``` + +Surprisingly, such a mechanism is not immediately obvious. + +First, the above program is a non starter. Not only does the SPL token +program not have the ability to iterate through all accounts for a given mint within a program, +but, since Solana transactions require the specification of all accounts being accessed +in a transaction (this is how it achieves parallelism), such a transaction's size would be +well over the limit. So modifying global state atomically in a single transaction is out of the +question. + +So if you can't do this on chain, one can try doing it off chain. One could write an program to +snapshot the pool state, and just airdrop tokens onto the pool. This works, but +adds an additional layer of trust. Who snapshots the pool state? At what time? +How do you know they calculated the rewards correctly? What happens if my reward was not given? +This is not auditable or verifiable. And if you want to answer these questions, requires +complex off-chain protocols that require either fancy cryptography or effectively +recreating a BFT system off chain. + +Another solution considerered was to use a uniswap-style AMM pool (without the swapping). +This has a lot of advantages. First it's easy to reason about and implement in a single transaction. +To drop rewards gloablly onto the pool, one can deposit funds directly into the pool, in which case +the reward is automatically received by owners of the staking pool token upon redemption, a process +known as "gulping"--since dropping rewards increases the total value of the pool +while their proportion of the pool remained constant. + +However, there are enough downsides with using an AMM style pool to offset the convience. +Unfortunately, it loses the nice balance isolation property **Member** accounts have, because +tokens have to be pooled into the same vault, which is an additional security concern that could +easily lead to loss of funds, e.g., if there's a bug in the redemption calculation. Moreover, dropping +arbitrary tokens onto the pool is a challenge. Not only do you have to create new pool vaults for +every new token you drop onto the pool, but you also need to have stakers purchase those tokens to enter +the pool, effectively requiring one to stake other unintended tokens. An additional oddity is that +as rewards are dropped onto the pool, the price to enter the pool monotonically increases. Remember, entering this +type of pool requires "creating" pool tokens, i.e., depositing enough tokens so that you don't dilute +any other member. So if a single pool token represents one SPL token. And if an additional SPL token is dropped onto every +member of the pool, all the existing member's shares are now worth two SPL tokens. So to enter the pool without +dilution, one would have to "create" at a price of 2 SPL tokens per share. This means that rewarding +stakers becomes more expensive over time. One could of course solve this problem by implementing +arbitrary `n:m` pool token splits, which leads right back to the problem of mutating global account +state for an SPL token. + +Furthermore, dropping arbitrary program accounts as rewards hasn't even been covered, for example, +locked token rewards, which of course can't be dropped directly onto an AMM style pool, since they are not tokens. +So, if one did go with an AMM style pool, one would need a separate mechanism for handling more general rewards like +locked token accounts. Ideally, there would be a single mechanism for both. + +## Reward Vendors + +Instead of trying to *push* rewards to users via a direct transfer or airdrop, one can use a *polling* model +where users effectively event source a log on demand, providing a proof one is eligible for the reward. + +When a reward is created, the program must do two things: + +1) Create a **Reward Vendor** account with an associated token vault holding the reward. +2) Assign the **Reward Vendor** the next available position in a **Reward Event Queue**. Then, to retrieve +a reward, a staker invokes the `ClaimReward` command, providing a proof that the funds were +staked at the time of the reward being dropped, and in response, the program transfers or, +some might say, *vends* the proportion of the dropped reward to the polling **Member**. The +operation completes by incrementing the **Member**'s queue cursor, ensuring that a given +reward can only be processed once. + +This allows the program to drop rewards on the stake pool in a way that is +on chain and verifiable. Of course, it requires an external trigger, some account willing to +transfer funds to a new **RewardVendor**, but that is outside of the scope of the staking +program. The reward dropper can be an off chain BFT committee, or it can be an on-chain multisig. +It can be a charitable individual, or funds can flow directly from a fee paying program such as the DEX, +which itself can create a Reward Vendor from fees collected. It doesn't matter to the **Registry** program. + +Note that this solution also allows for rewards to be denominated in any token, not just the token being staked. +Since rewards are paid out by the vendor immediately and to a token account of the **Member**'s +choosing, it *just works*. Even more, this extends to arbitrary program accounts, particularly +**Locked** tokens. A **Reward Vendor** needs to additionally know the accounts and instruction data +to relay to the program, but otherwise, the mechanism is the same. The details of **Locked** tokens will +be explained in an additional document. + +### Realizing Locked Rewards + +In addition to a vesting schedule, locked rewards are subject to a realization condition defined by the +staking program. Specifically, locked tokens are **realized** upon completely unstaking. So if one never +unstakes and incurs the unbonding timelock, one never receives locked token rewards. + +## Misc + +### Member Accounts + +This document describes 4 vault types belonging to **Member** accounts. +However there are two types of balance groups: locked and unlocked. +As a result, there are really 8 vaults for each **Member**, 4 types of vaults in 2 separate sets, +each isolated from the other, so that locked tokens don't get mixed with unlocked tokens. + +## Future Work + +* Arbitrary program accounts as rewards. With the current design, it should be straightforward to generalize locked token rewards to arbitrary program accounts from arbitrary programs. diff --git a/migrations/deploy.js b/migrations/deploy.js new file mode 100644 index 0000000..8a51683 --- /dev/null +++ b/migrations/deploy.js @@ -0,0 +1,179 @@ +// deploy.js is a simple deploy script to initialize a program. This is run +// immediately after a deploy. + +const serumCmn = require("@project-serum/common"); +const anchor = require("@project-serum/anchor"); +const PublicKey = anchor.web3.PublicKey; + +module.exports = async function (provider) { + // Configure client to use the provider. + anchor.setProvider(provider); + + // Setup genesis state. + const registrarConfigs = await genesis(provider); + + // Program clients. + const lockup = anchor.workspace.Lockup; + const registry = anchor.workspace.Registry; + + // Registry state constructor. + await registry.state.rpc.new({ + accounts: { + lockupProgram: lockup.programId, + }, + }); + + // Lockup state constructor. + await lockup.state.rpc.new({ + accounts: { + authority: provider.wallet.publicKey, + }, + }); + + // Delete the default whitelist entries. + const defaultEntry = { programId: new anchor.web3.PublicKey() }; + await lockup.state.rpc.whitelistDelete(defaultEntry, { + accounts: { + authority: provider.wallet.publicKey, + }, + }); + + // Whitelist the registry. + await lockup.state.rpc.whitelistAdd( + { programId: registry.programId }, + { + accounts: { + authority: provider.wallet.publicKey, + }, + } + ); + + // Initialize all registrars. + const cfgKeys = Object.keys(registrarConfigs); + for (let k = 0; k < cfgKeys.length; k += 1) { + let r = registrarConfigs[cfgKeys[k]]; + const registrar = await registrarInit( + registry, + r.withdrawalTimelock, + r.stakeRate, + r.rewardQLen, + new anchor.web3.PublicKey(r.mint) + ); + r["registrar"] = registrar.toString(); + } + + // Generate code for whitelisting on UIs. + const code = generateCode(registry, lockup, registrarConfigs); + console.log("Generated whitelisted UI addresses:", code); +}; + +function generateCode(registry, lockup, registrarConfigs) { + const registrars = Object.keys(registrarConfigs) + .map((cfg) => `${cfg}: new PublicKey('${registrarConfigs[cfg].registrar}')`) + .join(","); + + const mints = Object.keys(registrarConfigs) + .map((cfg) => `${cfg}: new PublicKey('${registrarConfigs[cfg].mint}')`) + .join(","); + + return `{ +registryProgramId: new PublicKey('${registry.programId}'), +lockupProgramId: new PublicKey('${lockup.programId}'), +registrars: { ${registrars} }, +mints: { ${mints} }, + }`; +} + +async function genesis(provider) { + if ( + provider.connection._rpcEndpoint === "https://api.mainnet-beta.solana.com" + ) { + return { + srm: { + withdrawalTimelock: 60 * 60 * 24 * 7, // 1 week. + stakeRate: 500 * 10 ** 6, // 500 SRM. + rewardQLen: 150, + mint: "SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt", + }, + msrm: { + withdrawalTimelock: 60 * 60 * 24 * 7, // 1 week. + stakeRate: 1, + rewardQLen: 150, + mint: "MSRMcoVyrFxnSgo5uXwone5SKcGhT1KEJMFEkMEWf9L", + }, + }; + } else { + const [token1Mint, _god1] = await serumCmn.createMintAndVault( + provider, + new anchor.BN(10000000000000), + undefined, + 6 + ); + const [token2Mint, _god2] = await serumCmn.createMintAndVault( + provider, + new anchor.BN(10000000000), + undefined, + 0 + ); + return { + token1: { + withdrawalTimelock: 60 * 60 * 24 * 7, + stakeRate: 1000 * 10 ** 6, + rewardQLen: 150, + mint: token1Mint.toString(), + }, + token2: { + withdrawalTimelock: 60 * 60 * 24 * 7, + stakeRate: 1, + rewardQLen: 150, + mint: token2Mint.toString(), + }, + }; + } +} + +async function registrarInit( + registry, + _withdrawalTimelock, + _stakeRate, + rewardQLen, + mint +) { + const registrar = new anchor.web3.Account(); + const rewardQ = new anchor.web3.Account(); + const withdrawalTimelock = new anchor.BN(_withdrawalTimelock); + const stakeRate = new anchor.BN(_stakeRate); + const [ + registrarSigner, + nonce, + ] = await anchor.web3.PublicKey.findProgramAddress( + [registrar.publicKey.toBuffer()], + registry.programId + ); + const poolMint = await serumCmn.createMint( + registry.provider, + registrarSigner + ); + await registry.rpc.initialize( + mint, + registry.provider.wallet.publicKey, + nonce, + withdrawalTimelock, + stakeRate, + rewardQLen, + { + accounts: { + registrar: registrar.publicKey, + poolMint, + rewardEventQ: rewardQ.publicKey, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + signers: [registrar, rewardQ], + instructions: [ + await registry.account.registrar.createInstruction(registrar), + await registry.account.rewardQueue.createInstruction(rewardQ, 8250), + ], + } + ); + return registrar.publicKey; +} diff --git a/programs/lockup/Cargo.toml b/programs/lockup/Cargo.toml new file mode 100644 index 0000000..8edf6f3 --- /dev/null +++ b/programs/lockup/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "lockup" +version = "0.1.0" +description = "Created with Anchor" +edition = "2018" + +[lib] +crate-type = ["cdylib", "lib"] +name = "lockup" + +[features] +no-entrypoint = [] +cpi = ["no-entrypoint"] + +[dependencies] +anchor-lang = "0.2.1" +anchor-spl = "0.2.1" diff --git a/programs/lockup/Xargo.toml b/programs/lockup/Xargo.toml new file mode 100644 index 0000000..1744f09 --- /dev/null +++ b/programs/lockup/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] \ No newline at end of file diff --git a/programs/lockup/src/calculator.rs b/programs/lockup/src/calculator.rs new file mode 100644 index 0000000..c897fbd --- /dev/null +++ b/programs/lockup/src/calculator.rs @@ -0,0 +1,84 @@ +//! Utility functions for calculating unlock schedules for a vesting account. + +use crate::Vesting; + +pub fn available_for_withdrawal(vesting: &Vesting, current_ts: i64) -> u64 { + std::cmp::min(outstanding_vested(vesting, current_ts), balance(vesting)) +} + +// The amount of funds currently in the vault. +fn balance(vesting: &Vesting) -> u64 { + vesting + .outstanding + .checked_sub(vesting.whitelist_owned) + .unwrap() +} + +// The amount of outstanding locked tokens vested. Note that these +// tokens might have been transferred to whitelisted programs. +fn outstanding_vested(vesting: &Vesting, current_ts: i64) -> u64 { + total_vested(vesting, current_ts) + .checked_sub(withdrawn_amount(vesting)) + .unwrap() +} + +// Returns the amount withdrawn from this vesting account. +fn withdrawn_amount(vesting: &Vesting) -> u64 { + vesting + .start_balance + .checked_sub(vesting.outstanding) + .unwrap() +} + +// Returns the total vested amount up to the given ts, assuming zero +// withdrawals and zero funds sent to other programs. +fn total_vested(vesting: &Vesting, current_ts: i64) -> u64 { + if current_ts < vesting.start_ts { + 0 + } else if current_ts >= vesting.end_ts { + vesting.start_balance + } else { + linear_unlock(vesting, current_ts).unwrap() + } +} + +fn linear_unlock(vesting: &Vesting, current_ts: i64) -> Option { + // Signed division not supported. + let current_ts = current_ts as u64; + let start_ts = vesting.start_ts as u64; + let end_ts = vesting.end_ts as u64; + + // If we can't perfectly partition the vesting window, + // push the start of the window back so that we can. + // + // This has the effect of making the first vesting period shorter + // than the rest. + let shifted_start_ts = + start_ts.checked_sub(end_ts.checked_sub(start_ts)? % vesting.period_count)?; + + // Similarly, if we can't perfectly divide up the vesting rewards + // then make the first period act as a cliff, earning slightly more than + // subsequent periods. + let reward_overflow = vesting.start_balance % vesting.period_count; + + // Reward per period ignoring the overflow. + let reward_per_period = + (vesting.start_balance.checked_sub(reward_overflow)?).checked_div(vesting.period_count)?; + + // Number of vesting periods that have passed. + let current_period = { + let period_secs = + (end_ts.checked_sub(shifted_start_ts)?).checked_div(vesting.period_count)?; + let current_period_count = + (current_ts.checked_sub(shifted_start_ts)?).checked_div(period_secs)?; + std::cmp::min(current_period_count, vesting.period_count) + }; + + if current_period == 0 { + return Some(0); + } + + current_period + .checked_mul(reward_per_period)? + .checked_add(reward_overflow) +} diff --git a/programs/lockup/src/lib.rs b/programs/lockup/src/lib.rs new file mode 100644 index 0000000..a555b65 --- /dev/null +++ b/programs/lockup/src/lib.rs @@ -0,0 +1,534 @@ +//! A relatively advanced example of a lockup program. If you're new to Anchor, +//! it's suggested to start with the other examples. + +#![feature(proc_macro_hygiene)] + +use anchor_lang::prelude::*; +use anchor_lang::solana_program::instruction::Instruction; +use anchor_lang::solana_program::program; +use anchor_spl::token::{self, TokenAccount, Transfer}; + +mod calculator; + +#[program] +pub mod lockup { + use super::*; + + #[state] + pub struct Lockup { + /// The key with the ability to change the whitelist. + pub authority: Pubkey, + /// List of programs locked tokens can be sent to. These programs + /// are completely trusted to maintain the locked property. + pub whitelist: Vec, + } + + impl Lockup { + pub const WHITELIST_SIZE: usize = 10; + + pub fn new(ctx: Context) -> Result { + let mut whitelist = vec![]; + whitelist.resize(Self::WHITELIST_SIZE, Default::default()); + Ok(Lockup { + authority: *ctx.accounts.authority.key, + whitelist, + }) + } + + #[access_control(whitelist_auth(self, &ctx))] + pub fn whitelist_add(&mut self, ctx: Context, entry: WhitelistEntry) -> Result<()> { + if self.whitelist.len() == Self::WHITELIST_SIZE { + return Err(ErrorCode::WhitelistFull.into()); + } + if self.whitelist.contains(&entry) { + return Err(ErrorCode::WhitelistEntryAlreadyExists.into()); + } + self.whitelist.push(entry); + Ok(()) + } + + #[access_control(whitelist_auth(self, &ctx))] + pub fn whitelist_delete( + &mut self, + ctx: Context, + entry: WhitelistEntry, + ) -> Result<()> { + if !self.whitelist.contains(&entry) { + return Err(ErrorCode::WhitelistEntryNotFound.into()); + } + self.whitelist.retain(|e| e != &entry); + Ok(()) + } + + #[access_control(whitelist_auth(self, &ctx))] + pub fn set_authority(&mut self, ctx: Context, new_authority: Pubkey) -> Result<()> { + self.authority = new_authority; + Ok(()) + } + } + + #[access_control(CreateVesting::accounts(&ctx, nonce))] + pub fn create_vesting( + ctx: Context, + beneficiary: Pubkey, + deposit_amount: u64, + nonce: u8, + start_ts: i64, + end_ts: i64, + period_count: u64, + realizor: Option, + ) -> Result<()> { + if deposit_amount == 0 { + return Err(ErrorCode::InvalidDepositAmount.into()); + } + if !is_valid_schedule(start_ts, end_ts, period_count) { + return Err(ErrorCode::InvalidSchedule.into()); + } + let vesting = &mut ctx.accounts.vesting; + vesting.beneficiary = beneficiary; + vesting.mint = ctx.accounts.vault.mint; + vesting.vault = *ctx.accounts.vault.to_account_info().key; + vesting.period_count = period_count; + vesting.start_balance = deposit_amount; + vesting.end_ts = end_ts; + vesting.start_ts = start_ts; + vesting.created_ts = ctx.accounts.clock.unix_timestamp; + vesting.outstanding = deposit_amount; + vesting.whitelist_owned = 0; + vesting.grantor = *ctx.accounts.depositor_authority.key; + vesting.nonce = nonce; + vesting.realizor = realizor; + + token::transfer(ctx.accounts.into(), deposit_amount)?; + + Ok(()) + } + + #[access_control(is_realized(&ctx))] + pub fn withdraw(ctx: Context, amount: u64) -> Result<()> { + // Has the given amount vested? + if amount + > calculator::available_for_withdrawal( + &ctx.accounts.vesting, + ctx.accounts.clock.unix_timestamp, + ) + { + return Err(ErrorCode::InsufficientWithdrawalBalance.into()); + } + + // Transfer funds out. + let seeds = &[ + ctx.accounts.vesting.to_account_info().key.as_ref(), + &[ctx.accounts.vesting.nonce], + ]; + let signer = &[&seeds[..]]; + let cpi_ctx = CpiContext::from(&*ctx.accounts).with_signer(signer); + token::transfer(cpi_ctx, amount)?; + + // Bookeeping. + let vesting = &mut ctx.accounts.vesting; + vesting.outstanding -= amount; + + Ok(()) + } + + // Sends funds from the lockup program to a whitelisted program. + pub fn whitelist_withdraw<'a, 'b, 'c, 'info>( + ctx: Context<'a, 'b, 'c, 'info, WhitelistWithdraw<'info>>, + instruction_data: Vec, + amount: u64, + ) -> Result<()> { + let before_amount = ctx.accounts.transfer.vault.amount; + whitelist_relay_cpi( + &ctx.accounts.transfer, + ctx.remaining_accounts, + instruction_data, + )?; + let after_amount = ctx.accounts.transfer.vault.reload()?.amount; + + // CPI safety checks. + let withdraw_amount = before_amount - after_amount; + if withdraw_amount > amount { + return Err(ErrorCode::WhitelistWithdrawLimit)?; + } + + // Bookeeping. + ctx.accounts.transfer.vesting.whitelist_owned += withdraw_amount; + + Ok(()) + } + + // Sends funds from a whitelisted program back to the lockup program. + pub fn whitelist_deposit<'a, 'b, 'c, 'info>( + ctx: Context<'a, 'b, 'c, 'info, WhitelistDeposit<'info>>, + instruction_data: Vec, + ) -> Result<()> { + let before_amount = ctx.accounts.transfer.vault.amount; + whitelist_relay_cpi( + &ctx.accounts.transfer, + ctx.remaining_accounts, + instruction_data, + )?; + let after_amount = ctx.accounts.transfer.vault.reload()?.amount; + + // CPI safety checks. + let deposit_amount = after_amount - before_amount; + if deposit_amount <= 0 { + return Err(ErrorCode::InsufficientWhitelistDepositAmount)?; + } + if deposit_amount > ctx.accounts.transfer.vesting.whitelist_owned { + return Err(ErrorCode::WhitelistDepositOverflow)?; + } + + // Bookkeeping. + ctx.accounts.transfer.vesting.whitelist_owned -= deposit_amount; + + Ok(()) + } + + // Convenience function for UI's to calculate the withdrawable amount. + pub fn available_for_withdrawal(ctx: Context) -> Result<()> { + let available = calculator::available_for_withdrawal( + &ctx.accounts.vesting, + ctx.accounts.clock.unix_timestamp, + ); + // Log as string so that JS can read as a BN. + msg!(&format!("{{ \"result\": \"{}\" }}", available)); + Ok(()) + } +} + +#[derive(Accounts)] +pub struct Auth<'info> { + #[account(signer)] + authority: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct CreateVesting<'info> { + // Vesting. + #[account(init)] + vesting: ProgramAccount<'info, Vesting>, + #[account(mut)] + vault: CpiAccount<'info, TokenAccount>, + // Depositor. + #[account(mut)] + depositor: AccountInfo<'info>, + #[account(signer)] + depositor_authority: AccountInfo<'info>, + // Misc. + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, + rent: Sysvar<'info, Rent>, + clock: Sysvar<'info, Clock>, +} + +impl<'info> CreateVesting<'info> { + fn accounts(ctx: &Context, nonce: u8) -> Result<()> { + let vault_authority = Pubkey::create_program_address( + &[ + ctx.accounts.vesting.to_account_info().key.as_ref(), + &[nonce], + ], + ctx.program_id, + ) + .map_err(|_| ErrorCode::InvalidProgramAddress)?; + if ctx.accounts.vault.owner != vault_authority { + return Err(ErrorCode::InvalidVaultOwner)?; + } + + Ok(()) + } +} + +// All accounts not included here, i.e., the "remaining accounts" should be +// ordered according to the realization interface. +#[derive(Accounts)] +pub struct Withdraw<'info> { + // Vesting. + #[account(mut, has_one = beneficiary, has_one = vault)] + vesting: ProgramAccount<'info, Vesting>, + #[account(signer)] + beneficiary: AccountInfo<'info>, + #[account(mut)] + vault: CpiAccount<'info, TokenAccount>, + #[account(seeds = [vesting.to_account_info().key.as_ref(), &[vesting.nonce]])] + vesting_signer: AccountInfo<'info>, + // Withdraw receiving target.. + #[account(mut)] + token: CpiAccount<'info, TokenAccount>, + // Misc. + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, + clock: Sysvar<'info, Clock>, +} + +#[derive(Accounts)] +pub struct WhitelistWithdraw<'info> { + transfer: WhitelistTransfer<'info>, +} + +#[derive(Accounts)] +pub struct WhitelistDeposit<'info> { + transfer: WhitelistTransfer<'info>, +} + +#[derive(Accounts)] +pub struct WhitelistTransfer<'info> { + lockup: ProgramState<'info, Lockup>, + #[account(signer)] + beneficiary: AccountInfo<'info>, + whitelisted_program: AccountInfo<'info>, + + // Whitelist interface. + #[account(mut, has_one = beneficiary, has_one = vault)] + vesting: ProgramAccount<'info, Vesting>, + #[account(mut, "&vault.owner == vesting_signer.key")] + vault: CpiAccount<'info, TokenAccount>, + #[account(seeds = [vesting.to_account_info().key.as_ref(), &[vesting.nonce]])] + vesting_signer: AccountInfo<'info>, + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, + #[account(mut)] + whitelisted_program_vault: AccountInfo<'info>, + whitelisted_program_vault_authority: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct AvailableForWithdrawal<'info> { + vesting: ProgramAccount<'info, Vesting>, + clock: Sysvar<'info, Clock>, +} + +#[account] +pub struct Vesting { + /// The owner of this Vesting account. + pub beneficiary: Pubkey, + /// The mint of the SPL token locked up. + pub mint: Pubkey, + /// Address of the account's token vault. + pub vault: Pubkey, + /// The owner of the token account funding this account. + pub grantor: Pubkey, + /// The outstanding SRM deposit backing this vesting account. All + /// withdrawals will deduct this balance. + pub outstanding: u64, + /// The starting balance of this vesting account, i.e., how much was + /// originally deposited. + pub start_balance: u64, + /// The unix timestamp at which this vesting account was created. + pub created_ts: i64, + /// The time at which vesting begins. + pub start_ts: i64, + /// The time at which all tokens are vested. + pub end_ts: i64, + /// The number of times vesting will occur. For example, if vesting + /// is once a year over seven years, this will be 7. + pub period_count: u64, + /// The amount of tokens in custody of whitelisted programs. + pub whitelist_owned: u64, + /// Signer nonce. + pub nonce: u8, + /// The program that determines when the locked account is **realized**. + /// In addition to the lockup schedule, the program provides the ability + /// for applications to determine when locked tokens are considered earned. + /// For example, when earning locked tokens via the staking program, one + /// cannot receive the tokens until unstaking. As a result, if one never + /// unstakes, one would never actually receive the locked tokens. + pub realizor: Option, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct Realizor { + /// Program to invoke to check a realization condition. This program must + /// implement the `RealizeLock` trait. + pub program: Pubkey, + /// Address of an arbitrary piece of metadata interpretable by the realizor + /// program. For example, when a vesting account is allocated, the program + /// can define its realization condition as a function of some account + /// state. The metadata is the address of that account. + /// + /// In the case of staking, the metadata is a `Member` account address. When + /// the realization condition is checked, the staking program will check the + /// `Member` account defined by the `metadata` has no staked tokens. + pub metadata: Pubkey, +} + +#[derive(AnchorSerialize, AnchorDeserialize, PartialEq, Default, Copy, Clone)] +pub struct WhitelistEntry { + pub program_id: Pubkey, +} + +#[error] +pub enum ErrorCode { + #[msg("Vesting end must be greater than the current unix timestamp.")] + InvalidTimestamp, + #[msg("The number of vesting periods must be greater than zero.")] + InvalidPeriod, + #[msg("The vesting deposit amount must be greater than zero.")] + InvalidDepositAmount, + #[msg("The Whitelist entry is not a valid program address.")] + InvalidWhitelistEntry, + #[msg("Invalid program address. Did you provide the correct nonce?")] + InvalidProgramAddress, + #[msg("Invalid vault owner.")] + InvalidVaultOwner, + #[msg("Vault amount must be zero.")] + InvalidVaultAmount, + #[msg("Insufficient withdrawal balance.")] + InsufficientWithdrawalBalance, + #[msg("Whitelist is full")] + WhitelistFull, + #[msg("Whitelist entry already exists")] + WhitelistEntryAlreadyExists, + #[msg("Balance must go up when performing a whitelist deposit")] + InsufficientWhitelistDepositAmount, + #[msg("Cannot deposit more than withdrawn")] + WhitelistDepositOverflow, + #[msg("Tried to withdraw over the specified limit")] + WhitelistWithdrawLimit, + #[msg("Whitelist entry not found.")] + WhitelistEntryNotFound, + #[msg("You do not have sufficient permissions to perform this action.")] + Unauthorized, + #[msg("You are unable to realize projected rewards until unstaking.")] + UnableToWithdrawWhileStaked, + #[msg("The given lock realizor doesn't match the vesting account.")] + InvalidLockRealizor, + #[msg("You have not realized this vesting account.")] + UnrealizedVesting, + #[msg("Invalid vesting schedule given.")] + InvalidSchedule, +} + +impl<'a, 'b, 'c, 'info> From<&mut CreateVesting<'info>> + for CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> +{ + fn from(accounts: &mut CreateVesting<'info>) -> CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> { + let cpi_accounts = Transfer { + from: accounts.depositor.clone(), + to: accounts.vault.to_account_info(), + authority: accounts.depositor_authority.clone(), + }; + let cpi_program = accounts.token_program.clone(); + CpiContext::new(cpi_program, cpi_accounts) + } +} + +impl<'a, 'b, 'c, 'info> From<&Withdraw<'info>> for CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> { + fn from(accounts: &Withdraw<'info>) -> CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> { + let cpi_accounts = Transfer { + from: accounts.vault.to_account_info(), + to: accounts.token.to_account_info(), + authority: accounts.vesting_signer.to_account_info(), + }; + let cpi_program = accounts.token_program.to_account_info(); + CpiContext::new(cpi_program, cpi_accounts) + } +} + +#[access_control(is_whitelisted(transfer))] +pub fn whitelist_relay_cpi<'info>( + transfer: &WhitelistTransfer<'info>, + remaining_accounts: &[AccountInfo<'info>], + instruction_data: Vec, +) -> Result<()> { + let mut meta_accounts = vec![ + AccountMeta::new_readonly(*transfer.vesting.to_account_info().key, false), + AccountMeta::new(*transfer.vault.to_account_info().key, false), + AccountMeta::new_readonly(*transfer.vesting_signer.to_account_info().key, true), + AccountMeta::new_readonly(*transfer.token_program.to_account_info().key, false), + AccountMeta::new( + *transfer.whitelisted_program_vault.to_account_info().key, + false, + ), + AccountMeta::new_readonly( + *transfer + .whitelisted_program_vault_authority + .to_account_info() + .key, + false, + ), + ]; + meta_accounts.extend(remaining_accounts.iter().map(|a| { + if a.is_writable { + AccountMeta::new(*a.key, a.is_signer) + } else { + AccountMeta::new_readonly(*a.key, a.is_signer) + } + })); + let relay_instruction = Instruction { + program_id: *transfer.whitelisted_program.to_account_info().key, + accounts: meta_accounts, + data: instruction_data.to_vec(), + }; + + let seeds = &[ + transfer.vesting.to_account_info().key.as_ref(), + &[transfer.vesting.nonce], + ]; + let signer = &[&seeds[..]]; + let mut accounts = transfer.to_account_infos(); + accounts.extend_from_slice(&remaining_accounts); + program::invoke_signed(&relay_instruction, &accounts, signer).map_err(Into::into) +} + +pub fn is_whitelisted<'info>(transfer: &WhitelistTransfer<'info>) -> Result<()> { + if !transfer.lockup.whitelist.contains(&WhitelistEntry { + program_id: *transfer.whitelisted_program.key, + }) { + return Err(ErrorCode::WhitelistEntryNotFound.into()); + } + Ok(()) +} + +fn whitelist_auth(lockup: &Lockup, ctx: &Context) -> Result<()> { + if &lockup.authority != ctx.accounts.authority.key { + return Err(ErrorCode::Unauthorized.into()); + } + Ok(()) +} + +pub fn is_valid_schedule(start_ts: i64, end_ts: i64, period_count: u64) -> bool { + if end_ts <= start_ts { + return false; + } + if period_count > (end_ts - start_ts) as u64 { + return false; + } + if period_count == 0 { + return false; + } + true +} + +// Returns Ok if the locked vesting account has been "realized". Realization +// is application dependent. For example, in the case of staking, one must first +// unstake before being able to earn locked tokens. +fn is_realized(ctx: &Context) -> Result<()> { + if let Some(realizor) = &ctx.accounts.vesting.realizor { + let cpi_program = { + let p = ctx.remaining_accounts[0].clone(); + if p.key != &realizor.program { + return Err(ErrorCode::InvalidLockRealizor.into()); + } + p + }; + let cpi_accounts = ctx.remaining_accounts.to_vec()[1..].to_vec(); + let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts); + let vesting = (*ctx.accounts.vesting).clone(); + realize_lock::is_realized(cpi_ctx, vesting).map_err(|_| ErrorCode::UnrealizedVesting)?; + } + Ok(()) +} + +/// RealizeLock defines the interface an external program must implement if +/// they want to define a "realization condition" on a locked vesting account. +/// This condition must be satisfied *even if a vesting schedule has +/// completed*. Otherwise the user can never earn the locked funds. For example, +/// in the case of the staking program, one cannot received a locked reward +/// until one has completely unstaked. +#[interface] +pub trait RealizeLock<'info, T: Accounts<'info>> { + fn is_realized(ctx: Context, v: Vesting) -> ProgramResult; +} diff --git a/programs/registry/Cargo.toml b/programs/registry/Cargo.toml new file mode 100644 index 0000000..7d408ad --- /dev/null +++ b/programs/registry/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "registry" +version = "0.1.0" +description = "Created with Anchor" +edition = "2018" + +[lib] +crate-type = ["cdylib", "lib"] +name = "registry" + +[features] +no-entrypoint = [] +cpi = ["no-entrypoint"] + +[dependencies] +anchor-lang = "0.2.1" +anchor-spl = "0.2.1" +lockup = { path = "../lockup", features = ["cpi"] } diff --git a/programs/registry/Xargo.toml b/programs/registry/Xargo.toml new file mode 100644 index 0000000..1744f09 --- /dev/null +++ b/programs/registry/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] \ No newline at end of file diff --git a/programs/registry/src/lib.rs b/programs/registry/src/lib.rs new file mode 100644 index 0000000..4ed4289 --- /dev/null +++ b/programs/registry/src/lib.rs @@ -0,0 +1,1355 @@ +//! A relatively advanced example of a staking program. If you're new to Anchor, +//! it's suggested to start with the other examples. + +#![feature(proc_macro_hygiene)] + +use anchor_lang::prelude::*; +use anchor_lang::solana_program::program_option::COption; +use anchor_spl::token::{self, Mint, TokenAccount, Transfer}; +use lockup::{CreateVesting, RealizeLock, Realizor, Vesting}; +use std::convert::Into; + +#[program] +mod registry { + use super::*; + + #[state] + pub struct Registry { + pub lockup_program: Pubkey, + } + + impl Registry { + pub fn new(ctx: Context) -> Result { + Ok(Registry { + lockup_program: *ctx.accounts.lockup_program.key, + }) + } + + pub fn set_lockup_program( + &mut self, + ctx: Context, + lockup_program: Pubkey, + ) -> Result<()> { + // Hard code the authority because the first version of this program + // did not set an authority account in the global state. + // + // When removing the program's upgrade authority, one should remove + // this method first, redeploy, then remove the upgrade authority. + let expected: Pubkey = "HUgFuN4PbvF5YzjDSw9dQ8uTJUcwm2ANsMXwvRdY4ABx" + .parse() + .unwrap(); + if ctx.accounts.authority.key != &expected { + return Err(ErrorCode::InvalidProgramAuthority.into()); + } + + self.lockup_program = lockup_program; + + Ok(()) + } + } + + impl<'info> RealizeLock<'info, IsRealized<'info>> for Registry { + fn is_realized(ctx: Context, v: Vesting) -> ProgramResult { + if let Some(realizor) = &v.realizor { + if &realizor.metadata != ctx.accounts.member.to_account_info().key { + return Err(ErrorCode::InvalidRealizorMetadata.into()); + } + assert!(ctx.accounts.member.beneficiary == v.beneficiary); + let total_staked = + ctx.accounts.member_spt.amount + ctx.accounts.member_spt_locked.amount; + if total_staked != 0 { + return Err(ErrorCode::UnrealizedReward.into()); + } + } + Ok(()) + } + } + + #[access_control(Initialize::accounts(&ctx, nonce))] + pub fn initialize( + ctx: Context, + mint: Pubkey, + authority: Pubkey, + nonce: u8, + withdrawal_timelock: i64, + stake_rate: u64, + reward_q_len: u32, + ) -> Result<()> { + let registrar = &mut ctx.accounts.registrar; + + registrar.authority = authority; + registrar.nonce = nonce; + registrar.mint = mint; + registrar.pool_mint = *ctx.accounts.pool_mint.to_account_info().key; + registrar.stake_rate = stake_rate; + registrar.reward_event_q = *ctx.accounts.reward_event_q.to_account_info().key; + registrar.withdrawal_timelock = withdrawal_timelock; + + let reward_q = &mut ctx.accounts.reward_event_q; + reward_q + .events + .resize(reward_q_len as usize, Default::default()); + + Ok(()) + } + + pub fn update_registrar( + ctx: Context, + new_authority: Option, + withdrawal_timelock: Option, + ) -> Result<()> { + let registrar = &mut ctx.accounts.registrar; + + if let Some(new_authority) = new_authority { + registrar.authority = new_authority; + } + + if let Some(withdrawal_timelock) = withdrawal_timelock { + registrar.withdrawal_timelock = withdrawal_timelock; + } + + Ok(()) + } + + #[access_control(CreateMember::accounts(&ctx, nonce))] + pub fn create_member(ctx: Context, nonce: u8) -> Result<()> { + let member = &mut ctx.accounts.member; + member.registrar = *ctx.accounts.registrar.to_account_info().key; + member.beneficiary = *ctx.accounts.beneficiary.key; + member.balances = (&ctx.accounts.balances).into(); + member.balances_locked = (&ctx.accounts.balances_locked).into(); + member.nonce = nonce; + Ok(()) + } + + pub fn update_member(ctx: Context, metadata: Option) -> Result<()> { + let member = &mut ctx.accounts.member; + if let Some(m) = metadata { + member.metadata = m; + } + Ok(()) + } + + // Deposits that can only come directly from the member beneficiary. + pub fn deposit(ctx: Context, amount: u64) -> Result<()> { + token::transfer(ctx.accounts.into(), amount).map_err(Into::into) + } + + // Deposits that can only come from the beneficiary's vesting accounts. + pub fn deposit_locked(ctx: Context, amount: u64) -> Result<()> { + token::transfer(ctx.accounts.into(), amount).map_err(Into::into) + } + + #[access_control(no_available_rewards( + &ctx.accounts.reward_event_q, + &ctx.accounts.member, + &ctx.accounts.balances, + &ctx.accounts.balances_locked, + ))] + pub fn stake(ctx: Context, spt_amount: u64, locked: bool) -> Result<()> { + let balances = { + if locked { + &ctx.accounts.balances_locked + } else { + &ctx.accounts.balances + } + }; + + // Transfer tokens into the stake vault. + { + let seeds = &[ + ctx.accounts.registrar.to_account_info().key.as_ref(), + ctx.accounts.member.to_account_info().key.as_ref(), + &[ctx.accounts.member.nonce], + ]; + let member_signer = &[&seeds[..]]; + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.clone(), + token::Transfer { + from: balances.vault.to_account_info(), + to: balances.vault_stake.to_account_info(), + authority: ctx.accounts.member_signer.to_account_info(), + }, + member_signer, + ); + // Convert from stake-token units to mint-token units. + let token_amount = spt_amount + .checked_mul(ctx.accounts.registrar.stake_rate) + .unwrap(); + token::transfer(cpi_ctx, token_amount)?; + } + + // Mint pool tokens to the staker. + { + let seeds = &[ + ctx.accounts.registrar.to_account_info().key.as_ref(), + &[ctx.accounts.registrar.nonce], + ]; + let registrar_signer = &[&seeds[..]]; + + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.clone(), + token::MintTo { + mint: ctx.accounts.pool_mint.to_account_info(), + to: balances.spt.to_account_info(), + authority: ctx.accounts.registrar_signer.to_account_info(), + }, + registrar_signer, + ); + token::mint_to(cpi_ctx, spt_amount)?; + } + + // Update stake timestamp. + let member = &mut ctx.accounts.member; + member.last_stake_ts = ctx.accounts.clock.unix_timestamp; + + Ok(()) + } + + #[access_control(no_available_rewards( + &ctx.accounts.reward_event_q, + &ctx.accounts.member, + &ctx.accounts.balances, + &ctx.accounts.balances_locked, + ))] + pub fn start_unstake(ctx: Context, spt_amount: u64, locked: bool) -> Result<()> { + let balances = { + if locked { + &ctx.accounts.balances_locked + } else { + &ctx.accounts.balances + } + }; + + // Program signer. + let seeds = &[ + ctx.accounts.registrar.to_account_info().key.as_ref(), + ctx.accounts.member.to_account_info().key.as_ref(), + &[ctx.accounts.member.nonce], + ]; + let member_signer = &[&seeds[..]]; + + // Burn pool tokens. + { + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.clone(), + token::Burn { + mint: ctx.accounts.pool_mint.to_account_info(), + to: balances.spt.to_account_info(), + authority: ctx.accounts.member_signer.to_account_info(), + }, + member_signer, + ); + token::burn(cpi_ctx, spt_amount)?; + } + + // Convert from stake-token units to mint-token units. + let token_amount = spt_amount + .checked_mul(ctx.accounts.registrar.stake_rate) + .unwrap(); + + // Transfer tokens from the stake to pending vault. + { + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.clone(), + token::Transfer { + from: balances.vault_stake.to_account_info(), + to: balances.vault_pw.to_account_info(), + authority: ctx.accounts.member_signer.to_account_info(), + }, + member_signer, + ); + token::transfer(cpi_ctx, token_amount)?; + } + + // Print receipt. + let pending_withdrawal = &mut ctx.accounts.pending_withdrawal; + pending_withdrawal.burned = false; + pending_withdrawal.member = *ctx.accounts.member.to_account_info().key; + pending_withdrawal.start_ts = ctx.accounts.clock.unix_timestamp; + pending_withdrawal.end_ts = + ctx.accounts.clock.unix_timestamp + ctx.accounts.registrar.withdrawal_timelock; + pending_withdrawal.amount = token_amount; + pending_withdrawal.pool = ctx.accounts.registrar.pool_mint; + pending_withdrawal.registrar = *ctx.accounts.registrar.to_account_info().key; + pending_withdrawal.locked = locked; + + // Update stake timestamp. + let member = &mut ctx.accounts.member; + member.last_stake_ts = ctx.accounts.clock.unix_timestamp; + + Ok(()) + } + + pub fn end_unstake(ctx: Context) -> Result<()> { + if ctx.accounts.pending_withdrawal.end_ts > ctx.accounts.clock.unix_timestamp { + return Err(ErrorCode::UnstakeTimelock.into()); + } + + // Select which balance set this affects. + let balances = { + if ctx.accounts.pending_withdrawal.locked { + &ctx.accounts.member.balances_locked + } else { + &ctx.accounts.member.balances + } + }; + // Check the vaults given are corrrect. + if &balances.vault != ctx.accounts.vault.key { + return Err(ErrorCode::InvalidVault.into()); + } + if &balances.vault_pw != ctx.accounts.vault_pw.key { + return Err(ErrorCode::InvalidVault.into()); + } + + // Transfer tokens between vaults. + { + let seeds = &[ + ctx.accounts.registrar.to_account_info().key.as_ref(), + ctx.accounts.member.to_account_info().key.as_ref(), + &[ctx.accounts.member.nonce], + ]; + let signer = &[&seeds[..]]; + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.clone(), + Transfer { + from: ctx.accounts.vault_pw.to_account_info(), + to: ctx.accounts.vault.to_account_info(), + authority: ctx.accounts.member_signer.clone(), + }, + signer, + ); + token::transfer(cpi_ctx, ctx.accounts.pending_withdrawal.amount)?; + } + + // Burn the pending withdrawal receipt. + let pending_withdrawal = &mut ctx.accounts.pending_withdrawal; + pending_withdrawal.burned = true; + + Ok(()) + } + + pub fn withdraw(ctx: Context, amount: u64) -> Result<()> { + let seeds = &[ + ctx.accounts.registrar.to_account_info().key.as_ref(), + ctx.accounts.member.to_account_info().key.as_ref(), + &[ctx.accounts.member.nonce], + ]; + let signer = &[&seeds[..]]; + let cpi_accounts = Transfer { + from: ctx.accounts.vault.to_account_info(), + to: ctx.accounts.depositor.to_account_info(), + authority: ctx.accounts.member_signer.clone(), + }; + let cpi_program = ctx.accounts.token_program.clone(); + let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, signer); + + token::transfer(cpi_ctx, amount).map_err(Into::into) + } + + pub fn withdraw_locked(ctx: Context, amount: u64) -> Result<()> { + let seeds = &[ + ctx.accounts.registrar.to_account_info().key.as_ref(), + ctx.accounts.member.to_account_info().key.as_ref(), + &[ctx.accounts.member.nonce], + ]; + let signer = &[&seeds[..]]; + let cpi_accounts = Transfer { + from: ctx.accounts.member_vault.to_account_info(), + to: ctx.accounts.vesting_vault.to_account_info(), + authority: ctx.accounts.member_signer.clone(), + }; + let cpi_program = ctx.accounts.token_program.clone(); + let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, signer); + + token::transfer(cpi_ctx, amount).map_err(Into::into) + } + + #[access_control(DropReward::accounts(&ctx, nonce))] + pub fn drop_reward( + ctx: Context, + kind: RewardVendorKind, + total: u64, + expiry_ts: i64, + expiry_receiver: Pubkey, + nonce: u8, + ) -> Result<()> { + if total < ctx.accounts.pool_mint.supply { + return Err(ErrorCode::InsufficientReward.into()); + } + if ctx.accounts.clock.unix_timestamp >= expiry_ts { + return Err(ErrorCode::InvalidExpiry.into()); + } + if let RewardVendorKind::Locked { + start_ts, + end_ts, + period_count, + } = kind + { + if !lockup::is_valid_schedule(start_ts, end_ts, period_count) { + return Err(ErrorCode::InvalidVestingSchedule.into()); + } + } + + // Transfer funds into the vendor's vault. + token::transfer(ctx.accounts.into(), total)?; + + // Add the event to the reward queue. + let reward_q = &mut ctx.accounts.reward_event_q; + let cursor = reward_q.append(RewardEvent { + vendor: *ctx.accounts.vendor.to_account_info().key, + ts: ctx.accounts.clock.unix_timestamp, + locked: kind != RewardVendorKind::Unlocked, + })?; + + // Initialize the vendor. + let vendor = &mut ctx.accounts.vendor; + vendor.registrar = *ctx.accounts.registrar.to_account_info().key; + vendor.vault = *ctx.accounts.vendor_vault.to_account_info().key; + vendor.mint = ctx.accounts.vendor_vault.mint; + vendor.nonce = nonce; + vendor.pool_token_supply = ctx.accounts.pool_mint.supply; + vendor.reward_event_q_cursor = cursor; + vendor.start_ts = ctx.accounts.clock.unix_timestamp; + vendor.expiry_ts = expiry_ts; + vendor.expiry_receiver = expiry_receiver; + vendor.from = *ctx.accounts.depositor_authority.key; + vendor.total = total; + vendor.expired = false; + vendor.kind = kind; + + Ok(()) + } + + #[access_control(reward_eligible(&ctx.accounts.cmn))] + pub fn claim_reward(ctx: Context) -> Result<()> { + if RewardVendorKind::Unlocked != ctx.accounts.cmn.vendor.kind { + return Err(ErrorCode::ExpectedUnlockedVendor.into()); + } + // Reward distribution. + let spt_total = + ctx.accounts.cmn.balances.spt.amount + ctx.accounts.cmn.balances_locked.spt.amount; + let reward_amount = spt_total + .checked_mul(ctx.accounts.cmn.vendor.total) + .unwrap() + .checked_div(ctx.accounts.cmn.vendor.pool_token_supply) + .unwrap(); + assert!(reward_amount > 0); + + // Send reward to the given token account. + let seeds = &[ + ctx.accounts.cmn.registrar.to_account_info().key.as_ref(), + ctx.accounts.cmn.vendor.to_account_info().key.as_ref(), + &[ctx.accounts.cmn.vendor.nonce], + ]; + let signer = &[&seeds[..]]; + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.cmn.token_program.clone(), + token::Transfer { + from: ctx.accounts.cmn.vault.to_account_info(), + to: ctx.accounts.to.to_account_info(), + authority: ctx.accounts.cmn.vendor_signer.to_account_info(), + }, + signer, + ); + token::transfer(cpi_ctx, reward_amount)?; + + // Update member as having processed the reward. + let member = &mut ctx.accounts.cmn.member; + member.rewards_cursor = ctx.accounts.cmn.vendor.reward_event_q_cursor + 1; + + Ok(()) + } + + #[access_control(reward_eligible(&ctx.accounts.cmn))] + pub fn claim_reward_locked<'a, 'b, 'c, 'info>( + ctx: Context<'a, 'b, 'c, 'info, ClaimRewardLocked<'info>>, + nonce: u8, + ) -> Result<()> { + let (start_ts, end_ts, period_count) = match ctx.accounts.cmn.vendor.kind { + RewardVendorKind::Unlocked => return Err(ErrorCode::ExpectedLockedVendor.into()), + RewardVendorKind::Locked { + start_ts, + end_ts, + period_count, + } => (start_ts, end_ts, period_count), + }; + + // Reward distribution. + let spt_total = + ctx.accounts.cmn.balances.spt.amount + ctx.accounts.cmn.balances_locked.spt.amount; + let reward_amount = spt_total + .checked_mul(ctx.accounts.cmn.vendor.total) + .unwrap() + .checked_div(ctx.accounts.cmn.vendor.pool_token_supply) + .unwrap(); + assert!(reward_amount > 0); + + // Specify the vesting account's realizor, so that unlocks can only + // execute once completely unstaked. + let realizor = Some(Realizor { + program: *ctx.program_id, + metadata: *ctx.accounts.cmn.member.to_account_info().key, + }); + + // CPI: Create lockup account for the member's beneficiary. + let seeds = &[ + ctx.accounts.cmn.registrar.to_account_info().key.as_ref(), + ctx.accounts.cmn.vendor.to_account_info().key.as_ref(), + &[ctx.accounts.cmn.vendor.nonce], + ]; + let signer = &[&seeds[..]]; + let mut remaining_accounts: &[AccountInfo] = ctx.remaining_accounts; + let cpi_program = ctx.accounts.lockup_program.clone(); + let cpi_accounts = + CreateVesting::try_accounts(ctx.accounts.lockup_program.key, &mut remaining_accounts)?; + let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, signer); + lockup::cpi::create_vesting( + cpi_ctx, + ctx.accounts.cmn.member.beneficiary, + reward_amount, + nonce, + start_ts, + end_ts, + period_count, + realizor, + )?; + + // Make sure this reward can't be processed more than once. + let member = &mut ctx.accounts.cmn.member; + member.rewards_cursor = ctx.accounts.cmn.vendor.reward_event_q_cursor + 1; + + Ok(()) + } + + pub fn expire_reward(ctx: Context) -> Result<()> { + if ctx.accounts.clock.unix_timestamp < ctx.accounts.vendor.expiry_ts { + return Err(ErrorCode::VendorNotYetExpired.into()); + } + + // Send all remaining funds to the expiry receiver's token. + let seeds = &[ + ctx.accounts.registrar.to_account_info().key.as_ref(), + ctx.accounts.vendor.to_account_info().key.as_ref(), + &[ctx.accounts.vendor.nonce], + ]; + let signer = &[&seeds[..]]; + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.clone(), + token::Transfer { + to: ctx.accounts.expiry_receiver_token.to_account_info(), + from: ctx.accounts.vault.to_account_info(), + authority: ctx.accounts.vendor_signer.to_account_info(), + }, + signer, + ); + token::transfer(cpi_ctx, ctx.accounts.vault.amount)?; + + // Burn the vendor. + let vendor = &mut ctx.accounts.vendor; + vendor.expired = true; + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct Initialize<'info> { + #[account(init)] + registrar: ProgramAccount<'info, Registrar>, + #[account(init)] + reward_event_q: ProgramAccount<'info, RewardQueue>, + #[account("pool_mint.decimals == 0")] + pool_mint: CpiAccount<'info, Mint>, + rent: Sysvar<'info, Rent>, +} + +impl<'info> Initialize<'info> { + fn accounts(ctx: &Context>, nonce: u8) -> Result<()> { + let registrar_signer = Pubkey::create_program_address( + &[ + ctx.accounts.registrar.to_account_info().key.as_ref(), + &[nonce], + ], + ctx.program_id, + ) + .map_err(|_| ErrorCode::InvalidNonce)?; + if ctx.accounts.pool_mint.mint_authority != COption::Some(registrar_signer) { + return Err(ErrorCode::InvalidPoolMintAuthority.into()); + } + assert!(ctx.accounts.pool_mint.supply == 0); + Ok(()) + } +} + +#[derive(Accounts)] +pub struct UpdateRegistrar<'info> { + #[account(mut, has_one = authority)] + registrar: ProgramAccount<'info, Registrar>, + #[account(signer)] + authority: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct CreateMember<'info> { + // Stake instance. + registrar: ProgramAccount<'info, Registrar>, + // Member. + #[account(init)] + member: ProgramAccount<'info, Member>, + #[account(signer)] + beneficiary: AccountInfo<'info>, + #[account( + "&balances.spt.owner == member_signer.key", + "balances.spt.mint == registrar.pool_mint", + "balances.vault.mint == registrar.mint" + )] + balances: BalanceSandboxAccounts<'info>, + #[account( + "&balances_locked.spt.owner == member_signer.key", + "balances_locked.spt.mint == registrar.pool_mint", + "balances_locked.vault.mint == registrar.mint" + )] + balances_locked: BalanceSandboxAccounts<'info>, + member_signer: AccountInfo<'info>, + // Misc. + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, + rent: Sysvar<'info, Rent>, +} + +impl<'info> CreateMember<'info> { + fn accounts(ctx: &Context, nonce: u8) -> Result<()> { + let seeds = &[ + ctx.accounts.registrar.to_account_info().key.as_ref(), + ctx.accounts.member.to_account_info().key.as_ref(), + &[nonce], + ]; + let member_signer = Pubkey::create_program_address(seeds, ctx.program_id) + .map_err(|_| ErrorCode::InvalidNonce)?; + if &member_signer != ctx.accounts.member_signer.to_account_info().key { + return Err(ErrorCode::InvalidMemberSigner.into()); + } + + Ok(()) + } +} + +// When creating a member, the mints and owners of these accounts are correct. +// Upon creation, we assign the accounts. A onetime operation. +// When using a member, we check these accounts addresess are equal to the +// addresses stored on the member. If so, the correct accounts were given are +// correct. +#[derive(Accounts, Clone)] +pub struct BalanceSandboxAccounts<'info> { + #[account(mut)] + spt: CpiAccount<'info, TokenAccount>, + #[account(mut, "vault.owner == spt.owner")] + vault: CpiAccount<'info, TokenAccount>, + #[account( + mut, + "vault_stake.owner == spt.owner", + "vault_stake.mint == vault.mint" + )] + vault_stake: CpiAccount<'info, TokenAccount>, + #[account(mut, "vault_pw.owner == spt.owner", "vault_pw.mint == vault.mint")] + vault_pw: CpiAccount<'info, TokenAccount>, +} + +#[derive(Accounts)] +pub struct Ctor<'info> { + lockup_program: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct SetLockupProgram<'info> { + #[account(signer)] + authority: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct IsRealized<'info> { + #[account( + "&member.balances.spt == member_spt.to_account_info().key", + "&member.balances_locked.spt == member_spt_locked.to_account_info().key" + )] + member: ProgramAccount<'info, Member>, + member_spt: CpiAccount<'info, TokenAccount>, + member_spt_locked: CpiAccount<'info, TokenAccount>, +} + +#[derive(Accounts)] +pub struct UpdateMember<'info> { + #[account(mut, has_one = beneficiary)] + member: ProgramAccount<'info, Member>, + #[account(signer)] + beneficiary: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct Deposit<'info> { + // Member. + #[account(has_one = beneficiary)] + member: ProgramAccount<'info, Member>, + #[account(signer)] + beneficiary: AccountInfo<'info>, + #[account(mut, "vault.to_account_info().key == &member.balances.vault")] + vault: CpiAccount<'info, TokenAccount>, + // Depositor. + #[account(mut)] + depositor: AccountInfo<'info>, + #[account(signer, "depositor_authority.key == &member.beneficiary")] + depositor_authority: AccountInfo<'info>, + // Misc. + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct DepositLocked<'info> { + // Lockup whitelist relay interface. + #[account( + "vesting.to_account_info().owner == ®istry.lockup_program", + "vesting.beneficiary == member.beneficiary" + )] + vesting: CpiAccount<'info, Vesting>, + #[account(mut, "vesting_vault.key == &vesting.vault")] + vesting_vault: AccountInfo<'info>, + // Note: no need to verify the depositor_authority since the SPL program + // will fail the transaction if it's not correct. + #[account(signer)] + depositor_authority: AccountInfo<'info>, + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, + #[account( + mut, + "member_vault.to_account_info().key == &member.balances_locked.vault" + )] + member_vault: CpiAccount<'info, TokenAccount>, + #[account( + seeds = [ + registrar.to_account_info().key.as_ref(), + member.to_account_info().key.as_ref(), + &[member.nonce], + ] + )] + member_signer: AccountInfo<'info>, + + // Program specific. + registry: ProgramState<'info, Registry>, + registrar: ProgramAccount<'info, Registrar>, + #[account(belongs_to = registrar, has_one = beneficiary)] + member: ProgramAccount<'info, Member>, + #[account(signer)] + beneficiary: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct Stake<'info> { + // Global accounts for the staking instance. + #[account(has_one = pool_mint, has_one = reward_event_q)] + registrar: ProgramAccount<'info, Registrar>, + reward_event_q: ProgramAccount<'info, RewardQueue>, + #[account(mut)] + pool_mint: CpiAccount<'info, Mint>, + + // Member. + #[account(mut, has_one = beneficiary, belongs_to = registrar)] + member: ProgramAccount<'info, Member>, + #[account(signer)] + beneficiary: AccountInfo<'info>, + #[account("BalanceSandbox::from(&balances) == member.balances")] + balances: BalanceSandboxAccounts<'info>, + #[account("BalanceSandbox::from(&balances_locked) == member.balances_locked")] + balances_locked: BalanceSandboxAccounts<'info>, + + // Program signers. + #[account( + seeds = [ + registrar.to_account_info().key.as_ref(), + member.to_account_info().key.as_ref(), + &[member.nonce], + ] + )] + member_signer: AccountInfo<'info>, + #[account(seeds = [registrar.to_account_info().key.as_ref(), &[registrar.nonce]])] + registrar_signer: AccountInfo<'info>, + + // Misc. + clock: Sysvar<'info, Clock>, + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct StartUnstake<'info> { + // Stake instance globals. + #[account(has_one = reward_event_q)] + registrar: ProgramAccount<'info, Registrar>, + reward_event_q: ProgramAccount<'info, RewardQueue>, + #[account(mut)] + pool_mint: AccountInfo<'info>, + + // Member. + #[account(init)] + pending_withdrawal: ProgramAccount<'info, PendingWithdrawal>, + #[account(has_one = beneficiary, belongs_to = registrar)] + member: ProgramAccount<'info, Member>, + #[account(signer)] + beneficiary: AccountInfo<'info>, + #[account("BalanceSandbox::from(&balances) == member.balances")] + balances: BalanceSandboxAccounts<'info>, + #[account("BalanceSandbox::from(&balances_locked) == member.balances_locked")] + balances_locked: BalanceSandboxAccounts<'info>, + + // Programmatic signers. + #[account( + seeds = [ + registrar.to_account_info().key.as_ref(), + member.to_account_info().key.as_ref(), + &[member.nonce], + ] + )] + member_signer: AccountInfo<'info>, + + // Misc. + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, + clock: Sysvar<'info, Clock>, + rent: Sysvar<'info, Rent>, +} + +#[derive(Accounts)] +pub struct EndUnstake<'info> { + registrar: ProgramAccount<'info, Registrar>, + + #[account(belongs_to = registrar, has_one = beneficiary)] + member: ProgramAccount<'info, Member>, + #[account(signer)] + beneficiary: AccountInfo<'info>, + #[account(mut, belongs_to = registrar, belongs_to = member, "!pending_withdrawal.burned")] + pending_withdrawal: ProgramAccount<'info, PendingWithdrawal>, + + // If we had ordered maps implementing Accounts we could do a constraint like + // balances.get(pending_withdrawal.balance_id).vault == vault.key. + // + // Note: we do the constraints check in the handler, not here. + #[account(mut)] + vault: AccountInfo<'info>, + #[account(mut)] + vault_pw: AccountInfo<'info>, + + #[account( + seeds = [ + registrar.to_account_info().key.as_ref(), + member.to_account_info().key.as_ref(), + &[member.nonce], + ] + )] + member_signer: AccountInfo<'info>, + + clock: Sysvar<'info, Clock>, + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct Withdraw<'info> { + // Stake instance. + registrar: ProgramAccount<'info, Registrar>, + // Member. + #[account(belongs_to = registrar, has_one = beneficiary)] + member: ProgramAccount<'info, Member>, + #[account(signer)] + beneficiary: AccountInfo<'info>, + #[account(mut, "vault.to_account_info().key == &member.balances.vault")] + vault: CpiAccount<'info, TokenAccount>, + #[account( + seeds = [ + registrar.to_account_info().key.as_ref(), + member.to_account_info().key.as_ref(), + &[member.nonce], + ] + )] + member_signer: AccountInfo<'info>, + // Receiver. + #[account(mut)] + depositor: AccountInfo<'info>, + // Misc. + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct WithdrawLocked<'info> { + // Lockup whitelist relay interface. + #[account( + "vesting.to_account_info().owner == ®istry.lockup_program", + "vesting.beneficiary == member.beneficiary" + )] + vesting: CpiAccount<'info, Vesting>, + #[account(mut, "vesting_vault.key == &vesting.vault")] + vesting_vault: AccountInfo<'info>, + #[account(signer)] + vesting_signer: AccountInfo<'info>, + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, + #[account( + mut, + "member_vault.to_account_info().key == &member.balances_locked.vault" + )] + member_vault: CpiAccount<'info, TokenAccount>, + #[account( + seeds = [ + registrar.to_account_info().key.as_ref(), + member.to_account_info().key.as_ref(), + &[member.nonce], + ] + )] + member_signer: AccountInfo<'info>, + + // Program specific. + registry: ProgramState<'info, Registry>, + registrar: ProgramAccount<'info, Registrar>, + #[account(belongs_to = registrar, has_one = beneficiary)] + member: ProgramAccount<'info, Member>, + #[account(signer)] + beneficiary: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct DropReward<'info> { + // Staking instance. + #[account(has_one = reward_event_q, has_one = pool_mint)] + registrar: ProgramAccount<'info, Registrar>, + #[account(mut)] + reward_event_q: ProgramAccount<'info, RewardQueue>, + pool_mint: CpiAccount<'info, Mint>, + // Vendor. + #[account(init)] + vendor: ProgramAccount<'info, RewardVendor>, + #[account(mut)] + vendor_vault: CpiAccount<'info, TokenAccount>, + // Depositor. + #[account(mut)] + depositor: AccountInfo<'info>, + #[account(signer)] + depositor_authority: AccountInfo<'info>, + // Misc. + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, + clock: Sysvar<'info, Clock>, + rent: Sysvar<'info, Rent>, +} + +impl<'info> DropReward<'info> { + fn accounts(ctx: &Context, nonce: u8) -> Result<()> { + let vendor_signer = Pubkey::create_program_address( + &[ + ctx.accounts.registrar.to_account_info().key.as_ref(), + ctx.accounts.vendor.to_account_info().key.as_ref(), + &[nonce], + ], + ctx.program_id, + ) + .map_err(|_| ErrorCode::InvalidNonce)?; + if vendor_signer != ctx.accounts.vendor_vault.owner { + return Err(ErrorCode::InvalidVaultOwner.into()); + } + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct ClaimReward<'info> { + cmn: ClaimRewardCommon<'info>, + // Account to send reward to. + #[account(mut)] + to: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct ClaimRewardLocked<'info> { + cmn: ClaimRewardCommon<'info>, + registry: ProgramState<'info, Registry>, + #[account("lockup_program.key == ®istry.lockup_program")] + lockup_program: AccountInfo<'info>, +} + +// Accounts common to both claim reward locked/unlocked instructions. +#[derive(Accounts)] +pub struct ClaimRewardCommon<'info> { + // Stake instance. + registrar: ProgramAccount<'info, Registrar>, + // Member. + #[account(mut, belongs_to = registrar, has_one = beneficiary)] + member: ProgramAccount<'info, Member>, + #[account(signer)] + beneficiary: AccountInfo<'info>, + #[account("BalanceSandbox::from(&balances) == member.balances")] + balances: BalanceSandboxAccounts<'info>, + #[account("BalanceSandbox::from(&balances_locked) == member.balances_locked")] + balances_locked: BalanceSandboxAccounts<'info>, + // Vendor. + #[account(belongs_to = registrar, has_one = vault)] + vendor: ProgramAccount<'info, RewardVendor>, + #[account(mut)] + vault: AccountInfo<'info>, + #[account( + seeds = [ + registrar.to_account_info().key.as_ref(), + vendor.to_account_info().key.as_ref(), + &[vendor.nonce], + ] + )] + vendor_signer: AccountInfo<'info>, + // Misc. + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, + clock: Sysvar<'info, Clock>, +} + +#[derive(Accounts)] +pub struct ExpireReward<'info> { + // Staking instance globals. + registrar: ProgramAccount<'info, Registrar>, + // Vendor. + #[account(mut, belongs_to = registrar, has_one = vault, has_one = expiry_receiver)] + vendor: ProgramAccount<'info, RewardVendor>, + #[account(mut)] + vault: CpiAccount<'info, TokenAccount>, + #[account( + seeds = [ + registrar.to_account_info().key.as_ref(), + vendor.to_account_info().key.as_ref(), + &[vendor.nonce], + ] + )] + vendor_signer: AccountInfo<'info>, + // Receiver. + #[account(signer)] + expiry_receiver: AccountInfo<'info>, + #[account(mut)] + expiry_receiver_token: AccountInfo<'info>, + // Misc. + #[account("token_program.key == &token::ID")] + token_program: AccountInfo<'info>, + clock: Sysvar<'info, Clock>, +} + +#[account] +pub struct Registrar { + /// Priviledged account. + pub authority: Pubkey, + /// Nonce to derive the program-derived address owning the vaults. + pub nonce: u8, + /// Number of seconds that must pass for a withdrawal to complete. + pub withdrawal_timelock: i64, + /// Global event queue for reward vendoring. + pub reward_event_q: Pubkey, + /// Mint of the tokens that can be staked. + pub mint: Pubkey, + /// Staking pool token mint. + pub pool_mint: Pubkey, + /// The amount of tokens (not decimal) that must be staked to get a single + /// staking pool token. + pub stake_rate: u64, +} + +#[account] +pub struct Member { + /// Registrar the member belongs to. + pub registrar: Pubkey, + /// The effective owner of the Member account. + pub beneficiary: Pubkey, + /// Arbitrary metadata account owned by any program. + pub metadata: Pubkey, + /// Sets of balances owned by the Member. + pub balances: BalanceSandbox, + /// Locked balances owned by the Member. + pub balances_locked: BalanceSandbox, + /// Next position in the rewards event queue to process. + pub rewards_cursor: u32, + /// The clock timestamp of the last time this account staked or switched + /// entities. Used as a proof to reward vendors that the Member account + /// was staked at a given point in time. + pub last_stake_ts: i64, + /// Signer nonce. + pub nonce: u8, +} + +// BalanceSandbox defines isolated funds that can only be deposited/withdrawn +// into the program. +// +// Once controlled by the program, the associated `Member` account's beneficiary +// can send funds to/from any of the accounts within the sandbox, e.g., to +// stake. +#[derive(AnchorSerialize, AnchorDeserialize, Default, Debug, Clone, PartialEq)] +pub struct BalanceSandbox { + // Staking pool token. + pub spt: Pubkey, + // Free balance (deposit) vaults. + pub vault: Pubkey, + // Stake vaults. + pub vault_stake: Pubkey, + // Pending withdrawal vaults. + pub vault_pw: Pubkey, +} + +#[account] +pub struct PendingWithdrawal { + /// Registrar this account belongs to. + pub registrar: Pubkey, + /// Member this account belongs to. + pub member: Pubkey, + /// One time token. True if the withdrawal has been completed. + pub burned: bool, + /// The pool being withdrawn from. + pub pool: Pubkey, + /// Unix timestamp when this account was initialized. + pub start_ts: i64, + /// Timestamp when the pending withdrawal completes. + pub end_ts: i64, + /// The number of tokens redeemed from the staking pool. + pub amount: u64, + /// True if the withdrawal applies to locked balances. + pub locked: bool, +} + +#[account] +pub struct RewardQueue { + // Invariant: index is position of the next available slot. + head: u32, + // Invariant: index is position of the first (oldest) taken slot. + // Invariant: head == tail => queue is initialized. + // Invariant: index_of(head + 1) == index_of(tail) => queue is full. + tail: u32, + // Although a vec is used, the size is immutable. + events: Vec, +} + +impl RewardQueue { + pub fn append(&mut self, event: RewardEvent) -> Result { + let cursor = self.head; + + // Insert into next available slot. + let h_idx = self.index_of(self.head); + self.events[h_idx] = event; + + // Update head and tail counters. + let is_full = self.index_of(self.head + 1) == self.index_of(self.tail); + if is_full { + self.tail += 1; + } + self.head += 1; + + Ok(cursor) + } + + pub fn index_of(&self, counter: u32) -> usize { + counter as usize % self.capacity() + } + + pub fn capacity(&self) -> usize { + self.events.len() + } + + pub fn get(&self, cursor: u32) -> &RewardEvent { + &self.events[cursor as usize % self.capacity()] + } + + pub fn head(&self) -> u32 { + self.head + } + + pub fn tail(&self) -> u32 { + self.tail + } +} + +#[derive(Default, Clone, Copy, Debug, AnchorSerialize, AnchorDeserialize)] +pub struct RewardEvent { + vendor: Pubkey, + ts: i64, + locked: bool, +} + +#[account] +pub struct RewardVendor { + pub registrar: Pubkey, + pub vault: Pubkey, + pub mint: Pubkey, + pub nonce: u8, + pub pool_token_supply: u64, + pub reward_event_q_cursor: u32, + pub start_ts: i64, + pub expiry_ts: i64, + pub expiry_receiver: Pubkey, + pub from: Pubkey, + pub total: u64, + pub expired: bool, + pub kind: RewardVendorKind, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq)] +pub enum RewardVendorKind { + Unlocked, + Locked { + start_ts: i64, + end_ts: i64, + period_count: u64, + }, +} + +#[error] +pub enum ErrorCode { + #[msg("The given reward queue has already been initialized.")] + RewardQAlreadyInitialized, + #[msg("The nonce given doesn't derive a valid program address.")] + InvalidNonce, + #[msg("Invalid pool mint authority")] + InvalidPoolMintAuthority, + #[msg("Member signer doesn't match the derived address.")] + InvalidMemberSigner, + #[msg("The given vault owner must match the signing depositor.")] + InvalidVaultDeposit, + #[msg("The signing depositor doesn't match either of the balance accounts")] + InvalidDepositor, + #[msg("The vault given does not match the vault expected.")] + InvalidVault, + #[msg("Invalid vault owner.")] + InvalidVaultOwner, + #[msg("An unknown error has occured.")] + Unknown, + #[msg("The unstake timelock has not yet expired.")] + UnstakeTimelock, + #[msg("Reward vendors must have at least one token unit per pool token")] + InsufficientReward, + #[msg("Reward expiry must be after the current clock timestamp.")] + InvalidExpiry, + #[msg("The reward vendor has been expired.")] + VendorExpired, + #[msg("This reward has already been processed.")] + CursorAlreadyProcessed, + #[msg("The account was not staked at the time of this reward.")] + NotStakedDuringDrop, + #[msg("The vendor is not yet eligible for expiry.")] + VendorNotYetExpired, + #[msg("Please collect your reward before otherwise using the program.")] + RewardsNeedsProcessing, + #[msg("Locked reward vendor expected but an unlocked vendor was given.")] + ExpectedLockedVendor, + #[msg("Unlocked reward vendor expected but a locked vendor was given.")] + ExpectedUnlockedVendor, + #[msg("Locked deposit from an invalid deposit authority.")] + InvalidVestingSigner, + #[msg("Locked rewards cannot be realized until one unstaked all tokens.")] + UnrealizedReward, + #[msg("The beneficiary doesn't match.")] + InvalidBeneficiary, + #[msg("The given member account does not match the realizor metadata.")] + InvalidRealizorMetadata, + #[msg("Invalid vesting schedule for the locked reward.")] + InvalidVestingSchedule, + #[msg("Please specify the correct authority for this program.")] + InvalidProgramAuthority, +} + +impl<'a, 'b, 'c, 'info> From<&mut Deposit<'info>> + for CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> +{ + fn from(accounts: &mut Deposit<'info>) -> CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> { + let cpi_accounts = Transfer { + from: accounts.depositor.clone(), + to: accounts.vault.to_account_info(), + authority: accounts.depositor_authority.clone(), + }; + let cpi_program = accounts.token_program.clone(); + CpiContext::new(cpi_program, cpi_accounts) + } +} + +impl<'a, 'b, 'c, 'info> From<&mut DepositLocked<'info>> + for CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> +{ + fn from(accounts: &mut DepositLocked<'info>) -> CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> { + let cpi_accounts = Transfer { + from: accounts.vesting_vault.clone(), + to: accounts.member_vault.to_account_info(), + authority: accounts.depositor_authority.clone(), + }; + let cpi_program = accounts.token_program.clone(); + CpiContext::new(cpi_program, cpi_accounts) + } +} + +impl<'a, 'b, 'c, 'info> From<&mut DropReward<'info>> + for CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> +{ + fn from(accounts: &mut DropReward<'info>) -> CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> { + let cpi_accounts = Transfer { + from: accounts.depositor.clone(), + to: accounts.vendor_vault.to_account_info(), + authority: accounts.depositor_authority.clone(), + }; + let cpi_program = accounts.token_program.clone(); + CpiContext::new(cpi_program, cpi_accounts) + } +} + +impl<'info> From<&BalanceSandboxAccounts<'info>> for BalanceSandbox { + fn from(accs: &BalanceSandboxAccounts<'info>) -> Self { + Self { + spt: *accs.spt.to_account_info().key, + vault: *accs.vault.to_account_info().key, + vault_stake: *accs.vault_stake.to_account_info().key, + vault_pw: *accs.vault_pw.to_account_info().key, + } + } +} + +fn reward_eligible(cmn: &ClaimRewardCommon) -> Result<()> { + let vendor = &cmn.vendor; + let member = &cmn.member; + if vendor.expired { + return Err(ErrorCode::VendorExpired.into()); + } + if member.rewards_cursor > vendor.reward_event_q_cursor { + return Err(ErrorCode::CursorAlreadyProcessed.into()); + } + if member.last_stake_ts > vendor.start_ts { + return Err(ErrorCode::NotStakedDuringDrop.into()); + } + Ok(()) +} + +// Asserts the user calling the `Stake` instruction has no rewards available +// in the reward queue. +pub fn no_available_rewards<'info>( + reward_q: &ProgramAccount<'info, RewardQueue>, + member: &ProgramAccount<'info, Member>, + balances: &BalanceSandboxAccounts<'info>, + balances_locked: &BalanceSandboxAccounts<'info>, +) -> Result<()> { + let mut cursor = member.rewards_cursor; + + // If the member's cursor is less then the tail, then the ring buffer has + // overwritten those entries, so jump to the tail. + let tail = reward_q.tail(); + if cursor < tail { + cursor = tail; + } + + while cursor < reward_q.head() { + let r_event = reward_q.get(cursor); + if member.last_stake_ts < r_event.ts { + if balances.spt.amount > 0 || balances_locked.spt.amount > 0 { + return Err(ErrorCode::RewardsNeedsProcessing.into()); + } + } + cursor += 1; + } + + Ok(()) +} diff --git a/tests/lockup.js b/tests/lockup.js new file mode 100644 index 0000000..336e19d --- /dev/null +++ b/tests/lockup.js @@ -0,0 +1,946 @@ +const assert = require("assert"); +const anchor = require("@project-serum/anchor"); +const serumCmn = require("@project-serum/common"); +const TokenInstructions = require("@project-serum/serum").TokenInstructions; +const utils = require("./utils"); + +describe("Lockup and Registry", () => { + // Read the provider from the configured environmnet. + const provider = anchor.Provider.env(); + + // Configure the client to use the provider. + anchor.setProvider(provider); + + const lockup = anchor.workspace.Lockup; + const registry = anchor.workspace.Registry; + + let lockupAddress = null; + const WHITELIST_SIZE = 10; + + let mint = null; + let god = null; + + it("Sets up initial test state", async () => { + const [_mint, _god] = await serumCmn.createMintAndVault( + provider, + new anchor.BN(1000000) + ); + mint = _mint; + god = _god; + }); + + it("Is initialized!", async () => { + await lockup.state.rpc.new({ + accounts: { + authority: provider.wallet.publicKey, + }, + }); + + lockupAddress = await lockup.state.address(); + const lockupAccount = await lockup.state(); + + assert.ok(lockupAccount.authority.equals(provider.wallet.publicKey)); + assert.ok(lockupAccount.whitelist.length === WHITELIST_SIZE); + lockupAccount.whitelist.forEach((e) => { + assert.ok(e.programId.equals(new anchor.web3.PublicKey())); + }); + }); + + it("Deletes the default whitelisted addresses", async () => { + const defaultEntry = { programId: new anchor.web3.PublicKey() }; + await lockup.state.rpc.whitelistDelete(defaultEntry, { + accounts: { + authority: provider.wallet.publicKey, + }, + }); + }); + + it("Sets a new authority", async () => { + const newAuthority = new anchor.web3.Account(); + await lockup.state.rpc.setAuthority(newAuthority.publicKey, { + accounts: { + authority: provider.wallet.publicKey, + }, + }); + + let lockupAccount = await lockup.state(); + assert.ok(lockupAccount.authority.equals(newAuthority.publicKey)); + + await lockup.state.rpc.setAuthority(provider.wallet.publicKey, { + accounts: { + authority: newAuthority.publicKey, + }, + signers: [newAuthority], + }); + + lockupAccount = await lockup.state(); + assert.ok(lockupAccount.authority.equals(provider.wallet.publicKey)); + }); + + const entries = []; + + it("Adds to the whitelist", async () => { + const generateEntry = async () => { + let programId = new anchor.web3.Account().publicKey; + return { + programId, + }; + }; + + for (let k = 0; k < WHITELIST_SIZE; k += 1) { + entries.push(await generateEntry()); + } + + const accounts = { + authority: provider.wallet.publicKey, + }; + + await lockup.state.rpc.whitelistAdd(entries[0], { accounts }); + + let lockupAccount = await lockup.state(); + + assert.ok(lockupAccount.whitelist.length === 1); + assert.deepEqual(lockupAccount.whitelist, [entries[0]]); + + for (let k = 1; k < WHITELIST_SIZE; k += 1) { + await lockup.state.rpc.whitelistAdd(entries[k], { accounts }); + } + + lockupAccount = await lockup.state(); + + assert.deepEqual(lockupAccount.whitelist, entries); + + await assert.rejects( + async () => { + const e = await generateEntry(); + await lockup.state.rpc.whitelistAdd(e, { accounts }); + }, + (err) => { + assert.equal(err.code, 108); + assert.equal(err.msg, "Whitelist is full"); + return true; + } + ); + }); + + it("Removes from the whitelist", async () => { + await lockup.state.rpc.whitelistDelete(entries[0], { + accounts: { + authority: provider.wallet.publicKey, + }, + }); + let lockupAccount = await lockup.state(); + assert.deepEqual(lockupAccount.whitelist, entries.slice(1)); + }); + + const vesting = new anchor.web3.Account(); + let vestingAccount = null; + let vestingSigner = null; + + it("Creates a vesting account", async () => { + const startTs = new anchor.BN(Date.now() / 1000); + const endTs = new anchor.BN(startTs.toNumber() + 5); + const periodCount = new anchor.BN(2); + const beneficiary = provider.wallet.publicKey; + const depositAmount = new anchor.BN(100); + + const vault = new anchor.web3.Account(); + let [ + _vestingSigner, + nonce, + ] = await anchor.web3.PublicKey.findProgramAddress( + [vesting.publicKey.toBuffer()], + lockup.programId + ); + vestingSigner = _vestingSigner; + + await lockup.rpc.createVesting( + beneficiary, + depositAmount, + nonce, + startTs, + endTs, + periodCount, + null, // Lock realizor is None. + { + accounts: { + vesting: vesting.publicKey, + vault: vault.publicKey, + depositor: god, + depositorAuthority: provider.wallet.publicKey, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + signers: [vesting, vault], + instructions: [ + await lockup.account.vesting.createInstruction(vesting), + ...(await serumCmn.createTokenAccountInstrs( + provider, + vault.publicKey, + mint, + vestingSigner + )), + ], + } + ); + + vestingAccount = await lockup.account.vesting(vesting.publicKey); + + assert.ok(vestingAccount.beneficiary.equals(provider.wallet.publicKey)); + assert.ok(vestingAccount.mint.equals(mint)); + assert.ok(vestingAccount.grantor.equals(provider.wallet.publicKey)); + assert.ok(vestingAccount.outstanding.eq(depositAmount)); + assert.ok(vestingAccount.startBalance.eq(depositAmount)); + assert.ok(vestingAccount.whitelistOwned.eq(new anchor.BN(0))); + assert.equal(vestingAccount.nonce, nonce); + assert.ok(vestingAccount.createdTs.gt(new anchor.BN(0))); + assert.ok(vestingAccount.startTs.eq(startTs)); + assert.ok(vestingAccount.endTs.eq(endTs)); + assert.ok(vestingAccount.realizor === null); + }); + + it("Fails to withdraw from a vesting account before vesting", async () => { + await assert.rejects( + async () => { + await lockup.rpc.withdraw(new anchor.BN(100), { + accounts: { + vesting: vesting.publicKey, + beneficiary: provider.wallet.publicKey, + token: god, + vault: vestingAccount.vault, + vestingSigner: vestingSigner, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }); + }, + (err) => { + assert.equal(err.code, 107); + assert.equal(err.msg, "Insufficient withdrawal balance."); + return true; + } + ); + }); + + it("Waits for a vesting period to pass", async () => { + await serumCmn.sleep(10 * 1000); + }); + + it("Withdraws from the vesting account", async () => { + const token = await serumCmn.createTokenAccount( + provider, + mint, + provider.wallet.publicKey + ); + + await lockup.rpc.withdraw(new anchor.BN(100), { + accounts: { + vesting: vesting.publicKey, + beneficiary: provider.wallet.publicKey, + token, + vault: vestingAccount.vault, + vestingSigner, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }); + + vestingAccount = await lockup.account.vesting(vesting.publicKey); + assert.ok(vestingAccount.outstanding.eq(new anchor.BN(0))); + + const vaultAccount = await serumCmn.getTokenAccount( + provider, + vestingAccount.vault + ); + assert.ok(vaultAccount.amount.eq(new anchor.BN(0))); + + const tokenAccount = await serumCmn.getTokenAccount(provider, token); + assert.ok(tokenAccount.amount.eq(new anchor.BN(100))); + }); + + const registrar = new anchor.web3.Account(); + const rewardQ = new anchor.web3.Account(); + const withdrawalTimelock = new anchor.BN(4); + const stakeRate = new anchor.BN(2); + const rewardQLen = 170; + let registrarAccount = null; + let registrarSigner = null; + let nonce = null; + let poolMint = null; + + it("Creates registry genesis", async () => { + const [ + _registrarSigner, + _nonce, + ] = await anchor.web3.PublicKey.findProgramAddress( + [registrar.publicKey.toBuffer()], + registry.programId + ); + registrarSigner = _registrarSigner; + nonce = _nonce; + poolMint = await serumCmn.createMint(provider, registrarSigner); + }); + + it("Initializes registry's global state", async () => { + await registry.state.rpc.new({ + accounts: { lockupProgram: lockup.programId }, + }); + + const state = await registry.state(); + assert.ok(state.lockupProgram.equals(lockup.programId)); + + // Should not allow a second initializatoin. + await assert.rejects( + async () => { + await registry.state.rpc.new(lockup.programId); + }, + (err) => { + return true; + } + ); + }); + + it("Initializes the registrar", async () => { + await registry.rpc.initialize( + mint, + provider.wallet.publicKey, + nonce, + withdrawalTimelock, + stakeRate, + rewardQLen, + { + accounts: { + registrar: registrar.publicKey, + poolMint, + rewardEventQ: rewardQ.publicKey, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + signers: [registrar, rewardQ], + instructions: [ + await registry.account.registrar.createInstruction(registrar), + await registry.account.rewardQueue.createInstruction(rewardQ, 8250), + ], + } + ); + + registrarAccount = await registry.account.registrar(registrar.publicKey); + + assert.ok(registrarAccount.authority.equals(provider.wallet.publicKey)); + assert.equal(registrarAccount.nonce, nonce); + assert.ok(registrarAccount.mint.equals(mint)); + assert.ok(registrarAccount.poolMint.equals(poolMint)); + assert.ok(registrarAccount.stakeRate.eq(stakeRate)); + assert.ok(registrarAccount.rewardEventQ.equals(rewardQ.publicKey)); + assert.ok(registrarAccount.withdrawalTimelock.eq(withdrawalTimelock)); + }); + + const member = new anchor.web3.Account(); + let memberAccount = null; + let memberSigner = null; + let balances = null; + let balancesLocked = null; + + it("Creates a member", async () => { + const [ + _memberSigner, + nonce, + ] = await anchor.web3.PublicKey.findProgramAddress( + [registrar.publicKey.toBuffer(), member.publicKey.toBuffer()], + registry.programId + ); + memberSigner = _memberSigner; + + const [mainTx, _balances] = await utils.createBalanceSandbox( + provider, + registrarAccount, + memberSigner + ); + const [lockedTx, _balancesLocked] = await utils.createBalanceSandbox( + provider, + registrarAccount, + memberSigner + ); + + balances = _balances; + balancesLocked = _balancesLocked; + + const tx = registry.transaction.createMember(nonce, { + accounts: { + registrar: registrar.publicKey, + member: member.publicKey, + beneficiary: provider.wallet.publicKey, + memberSigner, + balances, + balancesLocked, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + instructions: [await registry.account.member.createInstruction(member)], + }); + + const signers = [member, provider.wallet.payer]; + + const allTxs = [mainTx, lockedTx, { tx, signers }]; + + let txSigs = await provider.sendAll(allTxs); + + memberAccount = await registry.account.member(member.publicKey); + + assert.ok(memberAccount.registrar.equals(registrar.publicKey)); + assert.ok(memberAccount.beneficiary.equals(provider.wallet.publicKey)); + assert.ok(memberAccount.metadata.equals(new anchor.web3.PublicKey())); + assert.equal( + JSON.stringify(memberAccount.balances), + JSON.stringify(balances) + ); + assert.equal( + JSON.stringify(memberAccount.balancesLocked), + JSON.stringify(balancesLocked) + ); + assert.ok(memberAccount.rewardsCursor === 0); + assert.ok(memberAccount.lastStakeTs.eq(new anchor.BN(0))); + }); + + it("Deposits (unlocked) to a member", async () => { + const depositAmount = new anchor.BN(120); + await registry.rpc.deposit(depositAmount, { + accounts: { + depositor: god, + depositorAuthority: provider.wallet.publicKey, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + vault: memberAccount.balances.vault, + beneficiary: provider.wallet.publicKey, + member: member.publicKey, + }, + }); + + const memberVault = await serumCmn.getTokenAccount( + provider, + memberAccount.balances.vault + ); + assert.ok(memberVault.amount.eq(depositAmount)); + }); + + it("Stakes to a member (unlocked)", async () => { + const stakeAmount = new anchor.BN(10); + await registry.rpc.stake(stakeAmount, false, { + accounts: { + // Stake instance. + registrar: registrar.publicKey, + rewardEventQ: rewardQ.publicKey, + poolMint, + // Member. + member: member.publicKey, + beneficiary: provider.wallet.publicKey, + balances, + balancesLocked, + // Program signers. + memberSigner, + registrarSigner, + // Misc. + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + }, + }); + + const vault = await serumCmn.getTokenAccount( + provider, + memberAccount.balances.vault + ); + const vaultStake = await serumCmn.getTokenAccount( + provider, + memberAccount.balances.vaultStake + ); + const spt = await serumCmn.getTokenAccount( + provider, + memberAccount.balances.spt + ); + + assert.ok(vault.amount.eq(new anchor.BN(100))); + assert.ok(vaultStake.amount.eq(new anchor.BN(20))); + assert.ok(spt.amount.eq(new anchor.BN(10))); + }); + + const unlockedVendor = new anchor.web3.Account(); + const unlockedVendorVault = new anchor.web3.Account(); + let unlockedVendorSigner = null; + + it("Drops an unlocked reward", async () => { + const rewardKind = { + unlocked: {}, + }; + const rewardAmount = new anchor.BN(200); + const expiry = new anchor.BN(Date.now() / 1000 + 5); + const [ + _vendorSigner, + nonce, + ] = await anchor.web3.PublicKey.findProgramAddress( + [registrar.publicKey.toBuffer(), unlockedVendor.publicKey.toBuffer()], + registry.programId + ); + unlockedVendorSigner = _vendorSigner; + + await registry.rpc.dropReward( + rewardKind, + rewardAmount, + expiry, + provider.wallet.publicKey, + nonce, + { + accounts: { + registrar: registrar.publicKey, + rewardEventQ: rewardQ.publicKey, + poolMint, + + vendor: unlockedVendor.publicKey, + vendorVault: unlockedVendorVault.publicKey, + + depositor: god, + depositorAuthority: provider.wallet.publicKey, + + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + signers: [unlockedVendorVault, unlockedVendor], + instructions: [ + ...(await serumCmn.createTokenAccountInstrs( + provider, + unlockedVendorVault.publicKey, + mint, + unlockedVendorSigner + )), + await registry.account.rewardVendor.createInstruction(unlockedVendor), + ], + } + ); + + const vendorAccount = await registry.account.rewardVendor( + unlockedVendor.publicKey + ); + + assert.ok(vendorAccount.registrar.equals(registrar.publicKey)); + assert.ok(vendorAccount.vault.equals(unlockedVendorVault.publicKey)); + assert.ok(vendorAccount.nonce === nonce); + assert.ok(vendorAccount.poolTokenSupply.eq(new anchor.BN(10))); + assert.ok(vendorAccount.expiryTs.eq(expiry)); + assert.ok(vendorAccount.expiryReceiver.equals(provider.wallet.publicKey)); + assert.ok(vendorAccount.total.eq(rewardAmount)); + assert.ok(vendorAccount.expired === false); + assert.ok(vendorAccount.rewardEventQCursor === 0); + assert.deepEqual(vendorAccount.kind, rewardKind); + + const rewardQAccount = await registry.account.rewardQueue( + rewardQ.publicKey + ); + assert.ok(rewardQAccount.head === 1); + assert.ok(rewardQAccount.tail === 0); + const e = rewardQAccount.events[0]; + assert.ok(e.vendor.equals(unlockedVendor.publicKey)); + assert.equal(e.locked, false); + }); + + it("Collects an unlocked reward", async () => { + const token = await serumCmn.createTokenAccount( + provider, + mint, + provider.wallet.publicKey + ); + await registry.rpc.claimReward({ + accounts: { + to: token, + cmn: { + registrar: registrar.publicKey, + + member: member.publicKey, + beneficiary: provider.wallet.publicKey, + balances, + balancesLocked, + + vendor: unlockedVendor.publicKey, + vault: unlockedVendorVault.publicKey, + vendorSigner: unlockedVendorSigner, + + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }, + }); + + let tokenAccount = await serumCmn.getTokenAccount(provider, token); + assert.ok(tokenAccount.amount.eq(new anchor.BN(200))); + + const memberAccount = await registry.account.member(member.publicKey); + assert.ok(memberAccount.rewardsCursor == 1); + }); + + const lockedVendor = new anchor.web3.Account(); + const lockedVendorVault = new anchor.web3.Account(); + let lockedVendorSigner = null; + let lockedRewardAmount = null; + let lockedRewardKind = null; + + it("Drops a locked reward", async () => { + lockedRewardKind = { + locked: { + startTs: new anchor.BN(Date.now() / 1000), + endTs: new anchor.BN(Date.now() / 1000 + 6), + periodCount: new anchor.BN(2), + }, + }; + lockedRewardAmount = new anchor.BN(200); + const expiry = new anchor.BN(Date.now() / 1000 + 5); + const [ + _vendorSigner, + nonce, + ] = await anchor.web3.PublicKey.findProgramAddress( + [registrar.publicKey.toBuffer(), lockedVendor.publicKey.toBuffer()], + registry.programId + ); + lockedVendorSigner = _vendorSigner; + + await registry.rpc.dropReward( + lockedRewardKind, + lockedRewardAmount, + expiry, + provider.wallet.publicKey, + nonce, + { + accounts: { + registrar: registrar.publicKey, + rewardEventQ: rewardQ.publicKey, + poolMint, + + vendor: lockedVendor.publicKey, + vendorVault: lockedVendorVault.publicKey, + + depositor: god, + depositorAuthority: provider.wallet.publicKey, + + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + signers: [lockedVendorVault, lockedVendor], + instructions: [ + ...(await serumCmn.createTokenAccountInstrs( + provider, + lockedVendorVault.publicKey, + mint, + lockedVendorSigner + )), + await registry.account.rewardVendor.createInstruction(lockedVendor), + ], + } + ); + + const vendorAccount = await registry.account.rewardVendor( + lockedVendor.publicKey + ); + + assert.ok(vendorAccount.registrar.equals(registrar.publicKey)); + assert.ok(vendorAccount.vault.equals(lockedVendorVault.publicKey)); + assert.ok(vendorAccount.nonce === nonce); + assert.ok(vendorAccount.poolTokenSupply.eq(new anchor.BN(10))); + assert.ok(vendorAccount.expiryTs.eq(expiry)); + assert.ok(vendorAccount.expiryReceiver.equals(provider.wallet.publicKey)); + assert.ok(vendorAccount.total.eq(lockedRewardAmount)); + assert.ok(vendorAccount.expired === false); + assert.ok(vendorAccount.rewardEventQCursor === 1); + assert.equal( + JSON.stringify(vendorAccount.kind), + JSON.stringify(lockedRewardKind) + ); + + const rewardQAccount = await registry.account.rewardQueue( + rewardQ.publicKey + ); + assert.ok(rewardQAccount.head === 2); + assert.ok(rewardQAccount.tail === 0); + const e = rewardQAccount.events[1]; + assert.ok(e.vendor.equals(lockedVendor.publicKey)); + assert.ok(e.locked === true); + }); + + let vendoredVesting = null; + let vendoredVestingVault = null; + let vendoredVestingSigner = null; + + it("Claims a locked reward", async () => { + vendoredVesting = new anchor.web3.Account(); + vendoredVestingVault = new anchor.web3.Account(); + let [ + _vendoredVestingSigner, + nonce, + ] = await anchor.web3.PublicKey.findProgramAddress( + [vendoredVesting.publicKey.toBuffer()], + lockup.programId + ); + vendoredVestingSigner = _vendoredVestingSigner; + const remainingAccounts = lockup.instruction.createVesting + .accounts({ + vesting: vendoredVesting.publicKey, + vault: vendoredVestingVault.publicKey, + depositor: lockedVendorVault.publicKey, + depositorAuthority: lockedVendorSigner, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }) + // Change the signer status on the vendor signer since it's signed by the program, not the + // client. + .map((meta) => + meta.pubkey === lockedVendorSigner ? { ...meta, isSigner: false } : meta + ); + + await registry.rpc.claimRewardLocked(nonce, { + accounts: { + registry: await registry.state.address(), + lockupProgram: lockup.programId, + cmn: { + registrar: registrar.publicKey, + + member: member.publicKey, + beneficiary: provider.wallet.publicKey, + balances, + balancesLocked, + + vendor: lockedVendor.publicKey, + vault: lockedVendorVault.publicKey, + vendorSigner: lockedVendorSigner, + + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }, + remainingAccounts, + signers: [vendoredVesting, vendoredVestingVault], + instructions: [ + await lockup.account.vesting.createInstruction(vendoredVesting), + ...(await serumCmn.createTokenAccountInstrs( + provider, + vendoredVestingVault.publicKey, + mint, + vendoredVestingSigner + )), + ], + }); + + const lockupAccount = await lockup.account.vesting( + vendoredVesting.publicKey + ); + + assert.ok(lockupAccount.beneficiary.equals(provider.wallet.publicKey)); + assert.ok(lockupAccount.mint.equals(mint)); + assert.ok(lockupAccount.vault.equals(vendoredVestingVault.publicKey)); + assert.ok(lockupAccount.outstanding.eq(lockedRewardAmount)); + assert.ok(lockupAccount.startBalance.eq(lockedRewardAmount)); + assert.ok(lockupAccount.endTs.eq(lockedRewardKind.locked.endTs)); + assert.ok( + lockupAccount.periodCount.eq(lockedRewardKind.locked.periodCount) + ); + assert.ok(lockupAccount.whitelistOwned.eq(new anchor.BN(0))); + assert.ok(lockupAccount.realizor.program.equals(registry.programId)); + assert.ok(lockupAccount.realizor.metadata.equals(member.publicKey)); + }); + + it("Waits for the lockup period to pass", async () => { + await serumCmn.sleep(10 * 1000); + }); + + it("Should fail to unlock an unrealized lockup reward", async () => { + const token = await serumCmn.createTokenAccount( + provider, + mint, + provider.wallet.publicKey + ); + await assert.rejects( + async () => { + const withdrawAmount = new anchor.BN(10); + await lockup.rpc.withdraw(withdrawAmount, { + accounts: { + vesting: vendoredVesting.publicKey, + beneficiary: provider.wallet.publicKey, + token, + vault: vendoredVestingVault.publicKey, + vestingSigner: vendoredVestingSigner, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + // TODO: trait methods generated on the client. Until then, we need to manually + // specify the account metas here. + remainingAccounts: [ + { pubkey: registry.programId, isWritable: false, isSigner: false }, + { pubkey: member.publicKey, isWritable: false, isSigner: false }, + { pubkey: balances.spt, isWritable: false, isSigner: false }, + { pubkey: balancesLocked.spt, isWritable: false, isSigner: false }, + ], + }); + }, + (err) => { + // Solana doesn't propagate errors across CPI. So we receive the registry's error code, + // not the lockup's. + const errorCode = "custom program error: 0x78"; + assert.ok(err.toString().split(errorCode).length === 2); + return true; + } + ); + }); + + const pendingWithdrawal = new anchor.web3.Account(); + + it("Unstakes (unlocked)", async () => { + const unstakeAmount = new anchor.BN(10); + + await registry.rpc.startUnstake(unstakeAmount, false, { + accounts: { + registrar: registrar.publicKey, + rewardEventQ: rewardQ.publicKey, + poolMint, + + pendingWithdrawal: pendingWithdrawal.publicKey, + member: member.publicKey, + beneficiary: provider.wallet.publicKey, + balances, + balancesLocked, + + memberSigner, + + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + signers: [pendingWithdrawal], + instructions: [ + await registry.account.pendingWithdrawal.createInstruction( + pendingWithdrawal + ), + ], + }); + + const vaultPw = await serumCmn.getTokenAccount( + provider, + memberAccount.balances.vaultPw + ); + const vaultStake = await serumCmn.getTokenAccount( + provider, + memberAccount.balances.vaultStake + ); + const spt = await serumCmn.getTokenAccount( + provider, + memberAccount.balances.spt + ); + + assert.ok(vaultPw.amount.eq(new anchor.BN(20))); + assert.ok(vaultStake.amount.eq(new anchor.BN(0))); + assert.ok(spt.amount.eq(new anchor.BN(0))); + }); + + const tryEndUnstake = async () => { + await registry.rpc.endUnstake({ + accounts: { + registrar: registrar.publicKey, + + member: member.publicKey, + beneficiary: provider.wallet.publicKey, + pendingWithdrawal: pendingWithdrawal.publicKey, + + vault: balances.vault, + vaultPw: balances.vaultPw, + + memberSigner, + + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + }, + }); + }; + + it("Fails to end unstaking before timelock", async () => { + await assert.rejects( + async () => { + await tryEndUnstake(); + }, + (err) => { + assert.equal(err.code, 109); + assert.equal(err.msg, "The unstake timelock has not yet expired."); + return true; + } + ); + }); + + it("Waits for the unstake period to end", async () => { + await serumCmn.sleep(5000); + }); + + it("Unstake finalizes (unlocked)", async () => { + await tryEndUnstake(); + + const vault = await serumCmn.getTokenAccount( + provider, + memberAccount.balances.vault + ); + const vaultPw = await serumCmn.getTokenAccount( + provider, + memberAccount.balances.vaultPw + ); + + assert.ok(vault.amount.eq(new anchor.BN(120))); + assert.ok(vaultPw.amount.eq(new anchor.BN(0))); + }); + + it("Withdraws deposits (unlocked)", async () => { + const token = await serumCmn.createTokenAccount( + provider, + mint, + provider.wallet.publicKey + ); + const withdrawAmount = new anchor.BN(100); + await registry.rpc.withdraw(withdrawAmount, { + accounts: { + registrar: registrar.publicKey, + member: member.publicKey, + beneficiary: provider.wallet.publicKey, + vault: memberAccount.balances.vault, + memberSigner, + depositor: token, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + }, + }); + + const tokenAccount = await serumCmn.getTokenAccount(provider, token); + assert.ok(tokenAccount.amount.eq(withdrawAmount)); + }); + + it("Should succesfully unlock a locked reward after unstaking", async () => { + const token = await serumCmn.createTokenAccount( + provider, + mint, + provider.wallet.publicKey + ); + + const withdrawAmount = new anchor.BN(7); + await lockup.rpc.withdraw(withdrawAmount, { + accounts: { + vesting: vendoredVesting.publicKey, + beneficiary: provider.wallet.publicKey, + token, + vault: vendoredVestingVault.publicKey, + vestingSigner: vendoredVestingSigner, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + // TODO: trait methods generated on the client. Until then, we need to manually + // specify the account metas here. + remainingAccounts: [ + { pubkey: registry.programId, isWritable: false, isSigner: false }, + { pubkey: member.publicKey, isWritable: false, isSigner: false }, + { pubkey: balances.spt, isWritable: false, isSigner: false }, + { pubkey: balancesLocked.spt, isWritable: false, isSigner: false }, + ], + }); + const tokenAccount = await serumCmn.getTokenAccount(provider, token); + assert.ok(tokenAccount.amount.eq(withdrawAmount)); + }); +}); diff --git a/tests/utils.js b/tests/utils.js new file mode 100644 index 0000000..3d56cfd --- /dev/null +++ b/tests/utils.js @@ -0,0 +1,66 @@ +const anchor = require("@project-serum/anchor"); +const serumCmn = require("@project-serum/common"); + +async function createBalanceSandbox(provider, r, registrySigner) { + const spt = new anchor.web3.Account(); + const vault = new anchor.web3.Account(); + const vaultStake = new anchor.web3.Account(); + const vaultPw = new anchor.web3.Account(); + + const lamports = await provider.connection.getMinimumBalanceForRentExemption( + 165 + ); + + const createSptIx = await serumCmn.createTokenAccountInstrs( + provider, + spt.publicKey, + r.poolMint, + registrySigner, + lamports + ); + const createVaultIx = await serumCmn.createTokenAccountInstrs( + provider, + vault.publicKey, + r.mint, + registrySigner, + lamports + ); + const createVaultStakeIx = await serumCmn.createTokenAccountInstrs( + provider, + vaultStake.publicKey, + r.mint, + registrySigner, + lamports + ); + const createVaultPwIx = await serumCmn.createTokenAccountInstrs( + provider, + vaultPw.publicKey, + r.mint, + registrySigner, + lamports + ); + let tx0 = new anchor.web3.Transaction(); + tx0.add( + ...createSptIx, + ...createVaultIx, + ...createVaultStakeIx, + ...createVaultPwIx + ); + let signers0 = [spt, vault, vaultStake, vaultPw]; + + const tx = { tx: tx0, signers: signers0 }; + + return [ + tx, + { + spt: spt.publicKey, + vault: vault.publicKey, + vaultStake: vaultStake.publicKey, + vaultPw: vaultPw.publicKey, + }, + ]; +} + +module.exports = { + createBalanceSandbox, +};