NeoVest is intentionally small and immutable. This document records the threat model, known limitations, audit status, and operational guidance.
The vault is a trustless escrow that holds NEP-17 tokens for one party (the beneficiary) on behalf of another (the owner). The contract has exactly one privileged role:
- Owner. Set at deploy time, immutable thereafter. The owner is the
only address allowed to deposit (create new locks) via NEP-17
transferto the vault. The owner canrevokelocks created withrevocable: true. The owner cannot otherwise modify, withdraw, or upgrade the contract.
Everything else is unprivileged:
- Only the beneficiary of a given lock can
claimagainst it. - Read methods (
getLock,vestedAmount,claimableAmount, etc.) are open to anyone. - The contract has no admin override, no pause function, no upgrade path, no destruction, and no fee recipient.
If the owner key is lost, no further locks can be created in that vault, but every existing lock continues to work for its beneficiary.
A NEP-17 transfer to the vault ends in a call to the vault's
onNEP17Payment(from, amount, data). That callback only tells the vault
which contract called it; it does not prove that the caller is a real
token or that any tokens moved. Any contract could call
onNEP17Payment directly with from = owner, report transfer success,
and answer symbol() with a real token's symbol. The vault therefore
requires Runtime.checkWitness(owner) inside the callback before it
records a lock. A deposit the owner never signed is rejected with
"VV: no owner witness".
Two consequences follow:
- The owner must be a signing account: a single-sig or multi-sig address. A smart contract cannot provide a witness inside another contract's callback, so a contract-owned vault could never receive deposits. The Deploy page cannot detect this; check the owner address before signing.
- Deposits must be signed with the
CustomContractsscope listing the token and the vault.CalledByEntryis not enough: the token is called by the transaction's entry script, but the vault is called by the token, which is one hop further thanCalledByEntryreaches. The dashboard sets this scope automatically. Wallets show it as a permission for exactly those two contracts; a prompt listing anything else is a reason to reject the transaction.
- No recovery for misconfigured locks. If the owner sets the wrong beneficiary address or the wrong schedule, the tokens vest as scheduled to the address recorded — there is no override. Verify form inputs carefully before signing.
- GAS earned on vested NEO is stranded. NEO held by the vault earns
GAS, which the NEO contract pays to the vault whenever its NEO balance
changes. The vault accepts that payment (it must, or every NEO claim and
revoke would fault) but records no lock for it and has no withdrawal
path, so the GAS stays in the contract permanently. Neither the owner
nor the beneficiary can claim it. The dashboard warns when the token
being vested is NEO. A permissionless sweep of surplus balances is a
candidate for a future contract version (see
ROADMAP.md); deployed vaults cannot be upgraded to it. - Tokens that arrive without a valid deposit are stranded too. The
same
from == nullpath applies to any token minted directly to the vault. A NEP-17 transfer from a non-owner is rejected and the transfer reverts, so ordinary mistaken transfers are safe; mints are not. - No update path. Bug fixes after deployment require a fresh
deployment. Migration from old to new vault is opt-in and per-lock,
driven by the beneficiary; see
ROADMAP.mdfor the design. - NEP-17 only. NEP-11 (NFTs) and other token standards are not supported.
- No on-chain price oracle. The "% of supply" figure shown by the UI
is computed off-chain from the token's
totalSupply()at view time. - Stepped tranches stored as serialized bytes. The contract validates the tranche array once at lock creation and stores the serialized bytes; reads re-deserialize.
- Block-timestamp granularity. Schedule math uses
Runtime.getTime(), which is approximate to the second. Schedules that rely on sub-block precision are not supported. - One vault invocation per transaction for
claimandrevoke. Both methods abort unlessRuntime.getInvocationCounter() == 1. The counter counts every call into the vault during the transaction, including read methods, so a script that callsclaimableAmountand thenclaim, or twoclaims for different locks, fails with"VV: re-entry". Eachclaimorrevokemust be the only vault call in its transaction. This is what blocks a malicious token from re-entering the vault during an outboundtransfer, and it also means another contract cannot compose a vault write into a larger script that touches the vault more than once. Read methods called on their own are unaffected.
Unaudited. The contract has been reviewed against the public Neo N3
audit corpus at
smartargs/neo-sc-audits
(Lyrebird, GhostMarket, FTW Overlord, GrantShares). Findings applied:
- Deposit witness required.
onPaymentaborts unlessRuntime.checkWitness(from)holds, so a contract posing as a token cannot create locks attributed to the owner. Covered bypayment_directCallSpoofingOwner_abortsandpayment_ownerSignedCalledByEntryOnly_aborts. - Re-entrancy guard.
claimandrevokereject any invocation withRuntime.getInvocationCounter() != 1. A malicious token contract cannot re-enter the vault during an outboundtransfercallback. - Permission scope narrowed. The contract is allowed to call
transferonly — not arbitrary methods on external contracts. - Mint callbacks tolerated, never recorded.
onPaymentreturns without effect whenfrom == null, which is how the NEO contract delivers GAS and how tokens report mints. Nothing a mint delivers can become a lock; see the stranded-GAS limitation above for the cost. - Hash inputs validated.
Hash160.isValidis applied to every hash-typed input (beneficiary, token, owner). - Stepped tranches bounded to 64 entries, capping the per-claim gas cost the owner can inflict on a beneficiary.
- Checks-Effects-Interactions ordering verified line-by-line in
claim,revoke, andonPayment. State is persisted before any cross-contract call. Helper.abort(reason)on every revert path. Every fault carries a"VV: …"reason string so wallets can surface useful failure messages.Contract.callreturn value checked. Bothnullandfalseare treated as failure and abort.
- Read the contract source and tests; they are intentionally small.
- Run
./gradlew :contract:testand read each assertion. - Deploy to testnet and exercise every path with realistic amounts.
- Have an independent reviewer repeat steps 1–3.
- Commission a paid audit before any mainnet deposit beyond test scale.
- Keep initial mainnet deposits small until the contract has lived through real on-chain activity for a sustained period.
The dashboard is a static page that reads everything from one RPC node. The "Verified" badge, the owner check on the create-lock form, and every amount shown are only as trustworthy as that node.
- The node is chosen at build time (
VITE_NETWORK/VITE_RPC_URL). A per-browser override exists only through theneovest.rpclocalStorage key; there is deliberately no URL parameter, so a link cannot point a visitor at an attacker's node. While an override is active every page shows a red banner naming the endpoint, with a one-click reset. - A connected wallet must be on the network the build reads. Contract hashes are the same on every Neo N3 network, and so are NEO and GAS, so a transaction signed by a wallet on the wrong network can execute for real there while the page shows another chain's state. When the wallet reports a different network (NeoLine chain id, WalletConnect chain reference) the dashboard covers the whole page with a blocking dialog until the wallet is switched or disconnected, and every write handler refuses to submit. With NeoLine installed the extension's network is read on page load as well, before any connection: the Manage and Deploy pages are blocked immediately and the read-only pages show a red strip.
- Before signing a transaction, compare the contract hash in the wallet prompt with the hash in the page URL, and the hash in the URL with the one published by the project. The dashboard cannot do that for you.
- The production build ships a Content-Security-Policy (as a
<meta>tag and, for hosts that read it, a_headersfile) that restricts scripts to the page's own origin and browser extensions. Seedocs/UI.md.
- Pin compiler versions (
gradle.properties) so the bytecode is reproducible. SeeVERIFY.md. - Treat each vault as immutable post-deploy. Parameter changes require a new deployment.
- Document the deployment. A README in your fork pointing at the contract hash and the source commit makes verification straightforward for everyone interacting with the vault.
- Owner key hygiene. The owner address is the only privileged role. Use a hardware wallet or multi-sig for any vault holding meaningful value. Once set, the owner cannot be changed, and it must be an account that can sign (not a contract).
Please do not open a public issue for security vulnerabilities. Email the maintainers at the address listed in the repository's GitHub profile, or use GitHub's private vulnerability reporting feature on the repository. A maintainer will acknowledge within 72 hours.