nonce-watchtower 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonce_watchtower-0.3.0/LICENSE +21 -0
- nonce_watchtower-0.3.0/MANIFEST.in +4 -0
- nonce_watchtower-0.3.0/PKG-INFO +466 -0
- nonce_watchtower-0.3.0/README.md +438 -0
- nonce_watchtower-0.3.0/examples/nonce-watchtower-stream.service +69 -0
- nonce_watchtower-0.3.0/examples/wallets.example.toml +27 -0
- nonce_watchtower-0.3.0/examples/watchtower.env.example +26 -0
- nonce_watchtower-0.3.0/nonce_watchtower.egg-info/PKG-INFO +466 -0
- nonce_watchtower-0.3.0/nonce_watchtower.egg-info/SOURCES.txt +66 -0
- nonce_watchtower-0.3.0/nonce_watchtower.egg-info/dependency_links.txt +1 -0
- nonce_watchtower-0.3.0/nonce_watchtower.egg-info/entry_points.txt +2 -0
- nonce_watchtower-0.3.0/nonce_watchtower.egg-info/top_level.txt +1 -0
- nonce_watchtower-0.3.0/pyproject.toml +44 -0
- nonce_watchtower-0.3.0/setup.cfg +4 -0
- nonce_watchtower-0.3.0/tests/__init__.py +0 -0
- nonce_watchtower-0.3.0/tests/fixtures/README.md +13 -0
- nonce_watchtower-0.3.0/tests/fixtures/gpa_canary.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/gpa_nonce_empty.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/gpa_nonce_real.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/mint_pyusd_t22.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/mint_usdc.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/program_jup.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/programdata_jup.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/rpc_error_excluded.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/sigs_2HYcWwR6ZzVeHfoSB2Z1MtytZwXMbMRpTqC36wJHfJHn.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/sigs_2bq82QLzZr2u1b7udPyH6x95NvkrR3Zipvs37gT5GE5Q.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/sigs_82FQGbm5h1DC89F3h8LLzgtLRG4xw2TiMFmUA37G7jZs.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/sigs_9nnX6BEZ8SURh9WscvxtCWuDhwzaTazkqrs2P6LnxLTn.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/sigs_HZa8Hb8F4EZJZiqTMn9Vb9SWekpuFLUwnAu2MQM5CxrS.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/squads_v3_phoenix.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/squads_v4_exponent.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/t22_mints.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/tabo_spl_delegates.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/tabo_t22_delegates.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/tx_nonce_create_3BxRHV8L.json +1 -0
- nonce_watchtower-0.3.0/tests/fixtures/tx_nonce_use_2qnC1mFf.json +1 -0
- nonce_watchtower-0.3.0/tests/helpers.py +132 -0
- nonce_watchtower-0.3.0/tests/test_decode.py +73 -0
- nonce_watchtower-0.3.0/tests/test_freshness.py +270 -0
- nonce_watchtower-0.3.0/tests/test_notify.py +644 -0
- nonce_watchtower-0.3.0/tests/test_packaging.py +273 -0
- nonce_watchtower-0.3.0/tests/test_provenance.py +202 -0
- nonce_watchtower-0.3.0/tests/test_redact.py +389 -0
- nonce_watchtower-0.3.0/tests/test_review_fixes.py +129 -0
- nonce_watchtower-0.3.0/tests/test_review_m1.py +193 -0
- nonce_watchtower-0.3.0/tests/test_rpc.py +46 -0
- nonce_watchtower-0.3.0/tests/test_scan.py +133 -0
- nonce_watchtower-0.3.0/tests/test_squads.py +201 -0
- nonce_watchtower-0.3.0/tests/test_stream.py +677 -0
- nonce_watchtower-0.3.0/tests/test_watch.py +232 -0
- nonce_watchtower-0.3.0/tests/test_ws.py +217 -0
- nonce_watchtower-0.3.0/watchtower/__init__.py +3 -0
- nonce_watchtower-0.3.0/watchtower/__main__.py +5 -0
- nonce_watchtower-0.3.0/watchtower/alerts.py +64 -0
- nonce_watchtower-0.3.0/watchtower/base58.py +45 -0
- nonce_watchtower-0.3.0/watchtower/cli.py +423 -0
- nonce_watchtower-0.3.0/watchtower/config.py +156 -0
- nonce_watchtower-0.3.0/watchtower/decode.py +76 -0
- nonce_watchtower-0.3.0/watchtower/diff.py +303 -0
- nonce_watchtower-0.3.0/watchtower/notify.py +445 -0
- nonce_watchtower-0.3.0/watchtower/pda.py +58 -0
- nonce_watchtower-0.3.0/watchtower/provenance.py +159 -0
- nonce_watchtower-0.3.0/watchtower/redact.py +181 -0
- nonce_watchtower-0.3.0/watchtower/rpc.py +269 -0
- nonce_watchtower-0.3.0/watchtower/scan.py +610 -0
- nonce_watchtower-0.3.0/watchtower/squads.py +153 -0
- nonce_watchtower-0.3.0/watchtower/stream.py +509 -0
- nonce_watchtower-0.3.0/watchtower/ws.py +323 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nonce-watchtower contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nonce-watchtower
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Read-only monitor for Solana durable-nonce accounts, token delegates and authorities held by a set of keys.
|
|
5
|
+
Author: clawboriclaw
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/clawboriclaw/nonce-watchtower
|
|
8
|
+
Project-URL: Repository, https://github.com/clawboriclaw/nonce-watchtower
|
|
9
|
+
Project-URL: Issues, https://github.com/clawboriclaw/nonce-watchtower/issues
|
|
10
|
+
Keywords: solana,security,monitoring,durable-nonce,multisig,squads,websocket,alerts
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: System :: Monitoring
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# nonce-watchtower
|
|
30
|
+
|
|
31
|
+
A read-only monitor for the **standing powers** attached to a set of Solana keys: the
|
|
32
|
+
things that let someone move funds or seize control later, without asking the key holder
|
|
33
|
+
again.
|
|
34
|
+
|
|
35
|
+
Given wallets, multisig-member keys, or a **Squads v4 multisig address** (it reads the
|
|
36
|
+
members itself), it lists:
|
|
37
|
+
|
|
38
|
+
1. **Durable-nonce accounts whose authority is one of those keys.** Pre-signed
|
|
39
|
+
transactions that use them never expire until the nonce is advanced. For each one it
|
|
40
|
+
looks up **who created it** (first transaction, funder, initial nonce authority, fee
|
|
41
|
+
payer), and flags a nonce funded or first authorized by a key outside the watched set.
|
|
42
|
+
2. **SPL Token and Token-2022 delegate approvals** on their token accounts (plus foreign
|
|
43
|
+
close authorities, frozen accounts, and Token-2022 *permanent delegates* on mints they
|
|
44
|
+
hold).
|
|
45
|
+
3. **Mint/freeze authorities** of the mints you name, and **upgrade authorities**
|
|
46
|
+
(plus last-deploy slot) of the programs you name.
|
|
47
|
+
|
|
48
|
+
`watch` mode polls on an interval; `stream` mode subscribes to Solana PubSub (WebSocket) and
|
|
49
|
+
re-scans within seconds of a change, with a full re-sync after every reconnect. Both keep a
|
|
50
|
+
local state file and alert when any of these change: stdout always, plus **Telegram**,
|
|
51
|
+
**Discord** and/or a generic JSON webhook.
|
|
52
|
+
|
|
53
|
+
It **never signs or sends transactions and holds no private keys**. It only needs public keys.
|
|
54
|
+
|
|
55
|
+
## Threat model
|
|
56
|
+
|
|
57
|
+
### The attack this is built around
|
|
58
|
+
|
|
59
|
+
On 1 April 2026 Drift Protocol lost roughly $270-285M. The attacker never broke Drift's
|
|
60
|
+
contracts. Instead they:
|
|
61
|
+
|
|
62
|
+
- created **durable-nonce accounts tied to Security Council members** (23-30 March),
|
|
63
|
+
- got two of the five signers of the 2-of-5 Squads multisig to approve transactions whose
|
|
64
|
+
real effect was misrepresented, and those signatures were bound to the nonces so they
|
|
65
|
+
**never expired**, and
|
|
66
|
+
- about nine days later, submitted the pre-signed transactions, took admin control, and
|
|
67
|
+
drained the protocol in minutes.
|
|
68
|
+
|
|
69
|
+
Sources: [CoinDesk, "How a Solana feature designed for convenience let an attacker drain $270 million from Drift"](https://www.coindesk.com/tech/2026/04/02/how-a-solana-feature-designed-for-convenience-let-an-attacker-drain-usd270-million-from-drift),
|
|
70
|
+
[Halborn, "Explained: The Drift Hack (April 2026)"](https://www.halborn.com/blog/post/explained-the-drift-hack-april-2026),
|
|
71
|
+
[BlockSec incident analysis](https://blocksec.com/blog/drift-protocol-incident-multisig-governance-compromise-via-durable-nonce-exploitation).
|
|
72
|
+
|
|
73
|
+
The nonce accounts sat on-chain, in public, for about nine days before they were used.
|
|
74
|
+
Any signer could have seen them with one query. This tool runs that query on a schedule,
|
|
75
|
+
along with the related standing-approval checks.
|
|
76
|
+
|
|
77
|
+
### What it detects
|
|
78
|
+
|
|
79
|
+
| Signal | Why it matters | Alert |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| New nonce account with a watched key as authority | Staging step for deferred pre-signed transactions | `new_nonce_account` (critical) |
|
|
82
|
+
| Nonce account funded (rent paid) by, or first initialized with an authority of, a key outside the watched set | Someone else staged a nonce on your key: the Drift pattern | `nonce_outside_creator` (high) |
|
|
83
|
+
| Nonce funded by a watched key, but the transaction fee was paid by an outside key | Sponsored/relayed fee: often benign, worth confirming | `nonce_outside_fee_payer` (medium) |
|
|
84
|
+
| Nonce creator could not be established (no history, or only the initialize step visible so the funder is unknown) | Unknown is not cleared | `provenance_unavailable` (warn) |
|
|
85
|
+
| Squads member added/removed, threshold or config authority changed | Who can approve changed | `multisig_member_added` / `_removed` / `_threshold_changed` / `_config_authority_changed` (critical) |
|
|
86
|
+
| Squads member permissions or time lock changed | Approval rules changed | `multisig_permissions_changed`, `multisig_time_lock_changed` (high) |
|
|
87
|
+
| Squads multisig has a config authority | That key can change members and threshold without a vote | `squads_config_authority` (medium) |
|
|
88
|
+
| Squads address is not a v4 multisig (wrong owner, v3, missing, undecodable) | Members are NOT being watched | `squads_<status>` (warn) |
|
|
89
|
+
| Nonce value changed | A durable-nonce transaction executed, or someone advanced the nonce | `nonce_advanced` (high) |
|
|
90
|
+
| New or changed token delegate, allowance increase | The delegate can move the tokens with no further signature | `new_delegate`, `delegate_changed`, `delegate_allowance_increased` (high) |
|
|
91
|
+
| Allowance decreased | The delegate may have spent the tokens | `delegate_allowance_decreased` (medium) |
|
|
92
|
+
| Foreign close authority, frozen account, permanent delegate | Third-party control over the account or its balance | medium |
|
|
93
|
+
| Mint/freeze/permanent-delegate/transfer-hook authority changed on a watched mint | Supply or freeze control moved | critical |
|
|
94
|
+
| Upgrade authority changed / program redeployed | Code-control takeover, or new bytecode | critical / high |
|
|
95
|
+
| A check stopped working | Changes in that area are no longer detected | `coverage_lost` (warn) |
|
|
96
|
+
| `stream`: live connection down, or a subscription rejected | Only periodic polling is running; changes are seen late | `coverage_lost` on `stream` / `stream:<subscription>` (warn) |
|
|
97
|
+
| `stream`: reconnected, full re-sync scan complete | States the outage window; net changes inside it are alerted normally | `stream_gap` (warn) |
|
|
98
|
+
| `stream`: reconnected but the re-sync scan was incomplete | The outage window is NOT verified yet | `stream_gap_unresolved` (warn) |
|
|
99
|
+
| `stream`: a nonce account with a watched authority appeared live but is gone by the follow-up scan | Created and closed (or re-authorized away) quickly, or the RPC lags | `nonce_seen_live` (critical) |
|
|
100
|
+
| An alert could not be delivered to Telegram/Discord/webhook | Alerts are queued and retried; other sinks are told | `alert_delivery_failed` (warn) |
|
|
101
|
+
| A sink's retry queue overflowed (more than 500 undelivered alerts) | Alerts were dropped: oldest non-critical first; the record of it is never dropped | `alert_queue_overflow` (high, critical if a critical alert was dropped) |
|
|
102
|
+
| The scan/alert/state cycle itself failed (bug, disk full, unwritable state) | Nothing is being detected or recorded until a cycle succeeds | `scan_failed` (high), sent straight to every sink; `stream` coverage `scan_failed` |
|
|
103
|
+
| `stream`: a nonce notification could not be decoded and the next scan does not explain it | A nonce created and closed in between would be invisible | `nonce_notification_undecodable` (high) |
|
|
104
|
+
| A known nonce account is missing from a scan, but a direct read shows it still exists | The RPC's answer was stale or partial | that wallet's nonce check becomes `unverified` (`coverage_lost`), the account is kept |
|
|
105
|
+
|
|
106
|
+
On the first run, every standing nonce and delegate is reported as `existing_*`, so a
|
|
107
|
+
nonce staged *before* you installed the tool does not get quietly accepted as baseline.
|
|
108
|
+
|
|
109
|
+
### Security design
|
|
110
|
+
|
|
111
|
+
- **Read-only by construction.** The RPC client only accepts an allow-list of methods
|
|
112
|
+
(`getProgramAccounts`, `getTokenAccountsByOwner`, `getAccountInfo`,
|
|
113
|
+
`getMultipleAccounts`, `getSlot`, and for nonce provenance `getSignaturesForAddress`,
|
|
114
|
+
`getTransaction`). Any other method raises before any network I/O. The PubSub client has
|
|
115
|
+
its own allow-list (`accountSubscribe`, `programSubscribe`, `logsSubscribe` and their
|
|
116
|
+
unsubscribes). The package has no signing code, and a test checks that.
|
|
117
|
+
- **Unknown is never reported as clean.** If a check cannot run, it is reported as
|
|
118
|
+
`unavailable`, `unverified` or `partial`, `scan` exits non-zero (bit 2), and `watch`
|
|
119
|
+
keeps the last known values and emits `coverage_lost`. An outage never looks like "the
|
|
120
|
+
nonce account disappeared".
|
|
121
|
+
- **Silent-filter canary.** An RPC that quietly returns `[]` for System-program
|
|
122
|
+
`getProgramAccounts` would make every wallet look clean. Each scan first queries the
|
|
123
|
+
bricked (currently 26) nonce accounts whose authority is the System program itself (no one can ever
|
|
124
|
+
sign as it, so they cannot be withdrawn). If that returns nothing, nonce coverage is
|
|
125
|
+
marked `unavailable`.
|
|
126
|
+
- **The server-side filter is not trusted.** Every returned nonce account is decoded
|
|
127
|
+
locally, and it is dropped unless its owner is the System program and its authority
|
|
128
|
+
bytes equal the queried key.
|
|
129
|
+
- **Secrets stay out of output.** RPC/WebSocket/webhook URLs and the Telegram bot token are
|
|
130
|
+
credentials. They are read from environment variables. The config file refuses inline
|
|
131
|
+
`rpc_url`, `ws_url`, `webhook_url`, `telegram_bot_token` and `discord_webhook_url`. Every
|
|
132
|
+
message shows only `scheme://host` or the sink name and HTTP status. Exception text (which can
|
|
133
|
+
echo a URL or token) goes through one central redactor (`watchtower/redact.py`) before it can
|
|
134
|
+
reach stdout, stderr, the state file or any sink: every configured secret (bot token, webhook
|
|
135
|
+
URLs and their path tokens, keyed RPC/WebSocket URLs: query strings, path API keys, userinfo)
|
|
136
|
+
is masked, and any other URL is cut to `scheme://host`. Tests inject exceptions carrying each
|
|
137
|
+
kind of secret through both `watch` and `stream` and check every output channel. Residual: under
|
|
138
|
+
the ambiguous keys `token=` and `auth=` (outside a URL), a value shaped like an address or
|
|
139
|
+
signature (32/64-byte base58) is left visible, because it is usually one; explicit credential
|
|
140
|
+
keys (`api_key`, `secret`, `password`, ...) are always masked. Recognised forms: `key=value`,
|
|
141
|
+
`key: value`, quoted values, JSON fields, `Authorization: Bearer|Basic|Token <value>` (header in
|
|
142
|
+
any case), a bare `Bearer <value>` (capital B only, so prose like "the bearer of" is untouched),
|
|
143
|
+
and dict values under a credential key in the state file. Key names must match exactly: out of
|
|
144
|
+
scope are unregistered secrets under other or compound key names (for example `key=`, `sig=`,
|
|
145
|
+
`password_hash`, `my_secret`), a bare lowercase `bearer <value>`, unquoted values
|
|
146
|
+
containing spaces, and multi-line forms (YAML blocks, XML); registered secrets (your configured
|
|
147
|
+
URLs and tokens) are masked wherever they appear. Webhooks must
|
|
148
|
+
be https; PubSub must be `wss://` (plain `ws://` only for localhost).
|
|
149
|
+
- **Answer age is bounded and checked.** Every state read (`getProgramAccounts`, `getAccountInfo`,
|
|
150
|
+
`getMultipleAccounts`, `getTokenAccountsByOwner`) asks for `finalized` commitment with its
|
|
151
|
+
context slot, and immediately before EACH read the tool takes a fresh reference from the same
|
|
152
|
+
endpoint. What that proves:
|
|
153
|
+
1. **Internal consistency:** the answer's context slot is at most `max_slot_lag` slots behind
|
|
154
|
+
the endpoint's own finalized slot (default 64, about 25 s; `max_slot_lag` / `--max-slot-lag`).
|
|
155
|
+
This catches an index lagging its node; the canary alone cannot, because old canary accounts
|
|
156
|
+
are still listed by an index that has not caught up with a nonce created seconds ago.
|
|
157
|
+
2. **Absolute age:** `getBlockTime` of that finalized slot is at most `max_block_age` seconds
|
|
158
|
+
before this machine's clock (default 120; `max_block_age` / `--max-block-age`). This catches
|
|
159
|
+
a node that is uniformly behind the chain, whose slot and index agree with each other but are
|
|
160
|
+
both old. It relies on this machine's clock being right (run NTP).
|
|
161
|
+
3. **Optional independent reference:** with `WATCHTOWER_REFERENCE_RPC_URL` set (name configurable
|
|
162
|
+
via `reference_rpc_url_env`; a credential, never printed), the primary's finalized slot may
|
|
163
|
+
trail that second RPC's by at most `max_slot_lag`.
|
|
164
|
+
|
|
165
|
+
Failing any of these, or an answer without a context slot, or a failed `getSlot`/`getBlockTime`,
|
|
166
|
+
makes that check `unverified` (nonces) or `unavailable`: a coverage gap, never clean. **What it
|
|
167
|
+
does not prove:** an endpoint that fabricates consistent, current-looking slots and block times
|
|
168
|
+
while omitting accounts passes 1 and 2; only an honest, independent reference (3) narrows that.
|
|
169
|
+
Inside the bounds an answer can still trail the chain by up to `max_slot_lag` slots. Raise the
|
|
170
|
+
limits if your provider load-balances across nodes that drift. Provenance history reads
|
|
171
|
+
(`getSignaturesForAddress`, `getTransaction`) are not age-checked.
|
|
172
|
+
- **Squads members are read, not trusted blindly.** The multisig account must be owned by the
|
|
173
|
+
Squads v4 program, carry the v4 `Multisig` discriminator, decode cleanly (permission masks < 8,
|
|
174
|
+
no duplicate members), and its decoded `create_key` + `bump` must derive its own address. Any
|
|
175
|
+
failure is reported (`wrong_owner`, `unsupported_v3`, `missing`, `undecodable`, `unavailable`),
|
|
176
|
+
the scan is incomplete (exit bit 2), and no member list is guessed. In `watch` mode, the last
|
|
177
|
+
verified member list stays watched while resolution is failing.
|
|
178
|
+
- **Provenance is decoded locally.** The creating System-program instruction (`CreateAccount`,
|
|
179
|
+
`CreateAccountWithSeed` or `InitializeNonceAccount`, top level or CPI, including v0 lookup-table
|
|
180
|
+
addresses) is decoded from raw instruction data, not from the RPC's parsed view.
|
|
181
|
+
- **State file** is written atomically with mode 0600.
|
|
182
|
+
- **Strict config.** Unknown keys are rejected. This includes the TOML trap of writing
|
|
183
|
+
`mints = [...]` after a `[[wallets]]` header, which would otherwise silently drop the list.
|
|
184
|
+
- **No third-party dependencies.** Python 3.11+ stdlib only, so there is no supply chain
|
|
185
|
+
beyond CPython. That includes the WebSocket client (`watchtower/ws.py`, a minimal RFC 6455
|
|
186
|
+
client: handshake with `Sec-WebSocket-Accept` check, masked frames, ping/pong, close,
|
|
187
|
+
fragmentation, size cap, no extensions).
|
|
188
|
+
|
|
189
|
+
### Not in scope / limits
|
|
190
|
+
|
|
191
|
+
- It sees **state, not intent**. Every nonce is still flagged for a human to confirm, even one
|
|
192
|
+
your own key created. Provenance answers only "which key funded the account, which authority it
|
|
193
|
+
was first initialized with, and who paid the fee". The funder and initial authority decide
|
|
194
|
+
"outside"; an outside fee payer alone is reported separately at medium.
|
|
195
|
+
"Created by watched key X" does not prove X's holder meant to: a stolen or tricked member key
|
|
196
|
+
looks the same. "Created by an outside key" is a strong signal, not proof of an attack (a
|
|
197
|
+
wallet app or relayer can legitimately pay fees).
|
|
198
|
+
- **Provenance limits.** It needs an RPC that serves full transaction history for the nonce
|
|
199
|
+
account. Many providers and self-hosted nodes prune old ledger data; then the oldest visible
|
|
200
|
+
transaction is not the creation, and the answer is `unverified`/`unavailable`, never a guess.
|
|
201
|
+
If only the `InitializeNonceAccount` step is visible (the `CreateAccount` was in an earlier
|
|
202
|
+
transaction that is not visible), the funder is unknown and the answer is `unverified`.
|
|
203
|
+
Histories longer than 10,000 signatures (a busy nonce) are not walked and are reported
|
|
204
|
+
`unavailable`. Only the 3 oldest successful transactions are inspected. If an account was closed
|
|
205
|
+
and re-created at the same address (this needs the address keypair), the first creation is
|
|
206
|
+
reported. A settled provenance is cached in the `watch` state file and not re-fetched, and a
|
|
207
|
+
later failed lookup never overwrites it.
|
|
208
|
+
- **It does not stop signing.** It complements pre-sign decoders such as
|
|
209
|
+
[sign-safe](https://github.com/lrafasouza/sign-safe-skill) and wallet-side nonce warnings.
|
|
210
|
+
It is the after-the-fact, always-on layer.
|
|
211
|
+
- **Polling latency (`watch`).** Default interval is 300 s. An attacker who stages a nonce and
|
|
212
|
+
uses it within one interval is caught only after the fact (`nonce_advanced` /
|
|
213
|
+
`nonce_account_gone`). Use `stream` (or a shorter interval) for council keys.
|
|
214
|
+
- **Streaming limits (`stream`).** Subscriptions use `finalized` commitment (the same view the
|
|
215
|
+
scan reads), so an alert lands roughly 15-30 s after the transaction, plus the scan time.
|
|
216
|
+
Notifications are triggers, not evidence: each one (debounced, at most one scan per
|
|
217
|
+
`--min-gap` seconds) runs the same full scan as `watch`. A WebSocket can drop notifications
|
|
218
|
+
without disconnecting, so a full re-sync scan also runs every `--resync-interval` seconds.
|
|
219
|
+
While the stream is down, `stream` keeps polling at that interval. A change that happens
|
|
220
|
+
and reverts inside an outage or between two scans is not visible; the outage itself is
|
|
221
|
+
always reported (`coverage_lost` on `stream`, then `stream_gap`). Some providers refuse
|
|
222
|
+
`programSubscribe` on the System or Token programs; that subscription is reported
|
|
223
|
+
`subscribe_failed` and its changes are seen only by polling. A watched mint's supply
|
|
224
|
+
changes do not trigger scans (only its other bytes do).
|
|
225
|
+
- **RPC availability.** Finding nonces by authority needs `getProgramAccounts` on the
|
|
226
|
+
System program with a `memcmp` filter. On 2026-10-06 the public
|
|
227
|
+
`api.mainnet-beta.solana.com` served it. Some providers block it (see "RPC" below), and
|
|
228
|
+
the canary will catch that.
|
|
229
|
+
- Mints and programs are checked only when you list them. The tool does not yet discover
|
|
230
|
+
every mint or program a key controls (M2).
|
|
231
|
+
- Programs under loader-v4 are reported as `unsupported_loader`.
|
|
232
|
+
- **Squads:** only v4 (`SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf`) is decoded. A v3
|
|
233
|
+
(`SMPLecH534NA9acpos4G6x7uf3LWbCAwZQE9e8ZekMu`) multisig is reported `unsupported_v3`: list its
|
|
234
|
+
members yourself. Only vault index 0 is derived and watched; vaults 1..255 are not. Members are
|
|
235
|
+
re-read every cycle, so a member change is seen within one polling interval, not instantly.
|
|
236
|
+
In `stream` mode the multisig account is subscribed, so a member change triggers a scan
|
|
237
|
+
within seconds. Spending limits, proposals and pending transactions are not inspected. SPL Governance (Realms)
|
|
238
|
+
and other multisig programs are not supported.
|
|
239
|
+
- A local attacker who can edit the state file can suppress alerts.
|
|
240
|
+
|
|
241
|
+
## Related work
|
|
242
|
+
|
|
243
|
+
- [solana-nonce-guard](https://github.com/AaronTan11/solana-nonce-guard) (Rust, MIT): audits a
|
|
244
|
+
Squads/SPL multisig for durable-nonce staging, and has a WebSocket monitor. It covers nonces
|
|
245
|
+
only.
|
|
246
|
+
- [solgov](https://github.com/EdgeVault/solgov) (TypeScript, MIT): governance monitoring for a
|
|
247
|
+
fixed list of about 50 protocols, including nonce detection on their signers.
|
|
248
|
+
- [sign-safe](https://github.com/lrafasouza/sign-safe-skill) (MIT): decodes a transaction before
|
|
249
|
+
you sign it.
|
|
250
|
+
|
|
251
|
+
nonce-watchtower works on any set of keys, combines nonces, delegates and authorities in one
|
|
252
|
+
diffing watcher, and is designed to fail loudly instead of reporting clean.
|
|
253
|
+
|
|
254
|
+
## Install
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
python3 -m venv .venv
|
|
258
|
+
.venv/bin/pip install nonce-watchtower # or, from a checkout: .venv/bin/pip install -e .
|
|
259
|
+
.venv/bin/watchtower --help
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Python 3.11+, no dependencies. The console script is `watchtower` (`python -m watchtower` works too).
|
|
263
|
+
|
|
264
|
+
## Usage
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
# one-off scan (exit: 0 clean, 1 findings, 2 incomplete coverage, 3 both, 64 usage)
|
|
268
|
+
watchtower scan <pubkey> [<pubkey> ...] [--mint MINT]... [--program PROGRAM_ID]... [--json]
|
|
269
|
+
|
|
270
|
+
# watch every member, vault 0 and (if set) the config authority of a Squads v4 multisig
|
|
271
|
+
watchtower scan --squads <MULTISIG_ADDRESS> [--squads ...] [<pubkey> ...] [--no-provenance]
|
|
272
|
+
|
|
273
|
+
# polling watch (rescans every --interval seconds)
|
|
274
|
+
export WATCHTOWER_RPC_URL='https://your-provider.example/?api-key=...' # optional, recommended
|
|
275
|
+
export WATCHTOWER_WEBHOOK_URL='https://hooks.slack.com/services/...' # optional
|
|
276
|
+
watchtower watch --config wallets.toml --interval 300
|
|
277
|
+
watchtower watch --config wallets.toml --once # one cycle, for cron/systemd timers
|
|
278
|
+
|
|
279
|
+
# live: WebSocket subscriptions, full re-sync every 300 s and after every reconnect
|
|
280
|
+
export WATCHTOWER_WS_URL='wss://your-provider.example/?api-key=...' # optional: derived from the RPC URL
|
|
281
|
+
watchtower stream --config wallets.toml [--resync-interval 300] [--min-gap 10] [--json]
|
|
282
|
+
|
|
283
|
+
# check that alerts actually arrive (sends one test message to every configured sink)
|
|
284
|
+
watchtower alert-test --config wallets.toml
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`watch --once` exits 0 when the cycle ran and every alert was delivered, and 2 when the cycle
|
|
288
|
+
failed, a configured alert sink did not accept its alerts, or alerts are still queued behind the
|
|
289
|
+
per-cycle message cap (stderr says `ALERTS NOT YET DELIVERED`; the next run sends them). Either
|
|
290
|
+
way a timer unit shows as failed.
|
|
291
|
+
|
|
292
|
+
See `examples/wallets.example.toml`. Top-level `squads`/`mints`/`programs` must come before the
|
|
293
|
+
first `[[wallets]]` block. `squads = ["<multisig address>"]` adds every member key, vault 0 and
|
|
294
|
+
any config authority to the watched set. The multisig account address is the one Squads shows in
|
|
295
|
+
its app URL, not the vault address.
|
|
296
|
+
|
|
297
|
+
`--squads` takes the **multisig account**, not the vault. Members found this way are labelled
|
|
298
|
+
`squads <addr>… member N (permissions)`, `vault 0` or `config authority`.
|
|
299
|
+
|
|
300
|
+
Nonce provenance runs by default. Each nonce account costs at least two extra RPC calls on the
|
|
301
|
+
first scan (`--no-provenance` skips it in `scan`; the report then says `provenance: skipped`).
|
|
302
|
+
|
|
303
|
+
Webhook payloads carry `text` (Slack), `content` (Discord) and a machine-readable `alerts`
|
|
304
|
+
array. If a delivery fails, the alerts are queued in the state file and retried next cycle.
|
|
305
|
+
|
|
306
|
+
### Alerts: Telegram and Discord
|
|
307
|
+
|
|
308
|
+
Set the secrets as environment variables (names can be changed in `wallets.toml`, see
|
|
309
|
+
`examples/watchtower.env.example`):
|
|
310
|
+
|
|
311
|
+
| Sink | Variables |
|
|
312
|
+
|---|---|
|
|
313
|
+
| Telegram | `WATCHTOWER_TELEGRAM_BOT_TOKEN` (from @BotFather) and `WATCHTOWER_TELEGRAM_CHAT_ID` (or `telegram_chat_id = ...` in the config; a chat id is not a secret). Optional `WATCHTOWER_TELEGRAM_THREAD_ID`: a forum topic id (positive integer), sent as `message_thread_id` with every message, including `scan_failed` and `alert-test` |
|
|
314
|
+
| Discord | `WATCHTOWER_DISCORD_WEBHOOK_URL` (`https://discord.com/api/webhooks/<id>/<token>`) |
|
|
315
|
+
| Generic webhook | `WATCHTOWER_WEBHOOK_URL` or `--webhook` |
|
|
316
|
+
|
|
317
|
+
Then run `watchtower alert-test --config wallets.toml`. Delivery rules:
|
|
318
|
+
|
|
319
|
+
- **Fail loud at startup.** A malformed token, a non-Discord or non-https webhook URL, or
|
|
320
|
+
Telegram with only one of token/chat id set is a startup error (exit 64), never a silently
|
|
321
|
+
disabled sink.
|
|
322
|
+
- **Retry, then queue.** Network errors, HTTP 429 (honouring `retry_after`) and 5xx are retried
|
|
323
|
+
3 times per message with backoff. A 4xx (bad token, bot not in the chat, deleted webhook) is
|
|
324
|
+
not retried. Undelivered alerts are kept per sink in the state file (up to 500) and retried
|
|
325
|
+
every cycle; a sink that fails never blocks the others.
|
|
326
|
+
- **Surface failures.** A failed delivery is printed to stderr (`ALERT DELIVERY FAILED`), sent as
|
|
327
|
+
`alert_delivery_failed` through the sinks that still work, and makes `watch --once` exit 2.
|
|
328
|
+
- **Overflow is never silent.** A queue holds at most 500 alerts. Beyond that the oldest
|
|
329
|
+
non-critical alerts are dropped first (critical ones only if nothing else is left), and one
|
|
330
|
+
`alert_queue_overflow` alert (count, oldest time, kinds) takes their place at the front of the
|
|
331
|
+
queue. It is never trimmed itself, is reported on stderr as `DROPPED`, and makes `watch --once`
|
|
332
|
+
exit 2. The dropped alerts are still in stdout/the journal.
|
|
333
|
+
- **A failed cycle is an alert, not a log line.** If the scan, diff, delivery or state write
|
|
334
|
+
raises, a `scan_failed` alert goes to stdout and straight to every sink (bypassing the queue and
|
|
335
|
+
state file, which may be what failed). A sink that misses it is retried on every later attempt.
|
|
336
|
+
In `stream` mode, retries back off after consecutive failures (`--min-gap`, doubling, capped
|
|
337
|
+
at `--resync-interval`, reset by the first fully successful cycle), and a fresh `scan_failed`
|
|
338
|
+
("STILL FAILING", with the gap's start time) is sent so that delivered ones are never more
|
|
339
|
+
than one `--resync-interval` apart; `watch` raises one on every failed cycle. In `stream` mode the `stream` check reads `scan_failed` and a gap is
|
|
340
|
+
open until a cycle fully succeeds; that cycle then delivers `coverage_lost` and `stream_gap`
|
|
341
|
+
through the normal, persisted path.
|
|
342
|
+
- **Deduplicate only unchanged repeats.** A non-critical alert is not sent again to a sink when
|
|
343
|
+
it is identical (apart from its timestamp) to the **latest** alert that sink saw for the same
|
|
344
|
+
subject, within `alert_dedup_seconds` (default 1800). This absorbs exact repeats such as a
|
|
345
|
+
recurring `alert_delivery_failed` notice. It never absorbs a change: if anything else was
|
|
346
|
+
alerted on that subject in between (A -> B -> A, for example a member added, removed and
|
|
347
|
+
re-added, or a check lost, restored and lost again), the repeat is sent, at every severity.
|
|
348
|
+
**Critical alerts are never deduplicated.** stdout always gets every alert. Delivery is at-least-once: a crash between
|
|
349
|
+
sending and saving the state can repeat a message.
|
|
350
|
+
- **Nothing is cut.** Long batches are split across messages (Telegram 4096, Discord 2000
|
|
351
|
+
characters). At most 10 HTTP sends per sink per cycle, counting every piece of a split line;
|
|
352
|
+
alerts are held whole for the next cycle rather than half-sent. The one exception: a single
|
|
353
|
+
alert so long that it alone needs more than 10 messages is sent whole when it is first in line,
|
|
354
|
+
since it could otherwise never be delivered.
|
|
355
|
+
Messages are plain text, and Discord mentions are disabled so on-chain strings cannot ping
|
|
356
|
+
`@everyone`.
|
|
357
|
+
|
|
358
|
+
### Streaming mode
|
|
359
|
+
|
|
360
|
+
`watchtower stream` opens one PubSub connection and subscribes, at `finalized` commitment, to:
|
|
361
|
+
|
|
362
|
+
| Subscription | Catches |
|
|
363
|
+
|---|---|
|
|
364
|
+
| `programSubscribe` System program, `dataSize 80` + authority `memcmp`, per watched key | A nonce account created for, or re-authorized to, a watched key. The authority is instruction data, not an account key, so a `logsSubscribe` mention filter would miss a nonce staged by an outside key. |
|
|
365
|
+
| `programSubscribe` SPL Token (`dataSize 165`) and Token-2022, owner `memcmp`, per watched key | Delegate approvals, allowance changes, close authorities, freezes |
|
|
366
|
+
| `accountSubscribe` each known nonce account | Nonce advanced, closed, or authority moved away |
|
|
367
|
+
| `accountSubscribe` each Squads multisig, watched mint, program and its ProgramData | Member/threshold changes, authority changes, upgrades |
|
|
368
|
+
|
|
369
|
+
Watched keys include every Squads member, vault 0 and config authority. New nonce accounts and
|
|
370
|
+
new members are subscribed after the scan that finds them.
|
|
371
|
+
|
|
372
|
+
Order of operations on every (re)connect: subscribe, wait for every subscription to be
|
|
373
|
+
confirmed or rejected, **then** run a full re-sync scan, so a change between the scan and the
|
|
374
|
+
subscription cannot fall through. Reconnects use exponential backoff with jitter (1 s doubling
|
|
375
|
+
to 60 s, reset after a connection stayed up 60 s). A connection that delivers no frame, not
|
|
376
|
+
even a pong to our 30 s ping, for 90 s is treated as dead.
|
|
377
|
+
|
|
378
|
+
After each scan the subscription set is brought in line with what was found: new nonce
|
|
379
|
+
accounts and members are subscribed, and targets that left (a closed nonce account, a removed
|
|
380
|
+
member) are unsubscribed. A nonce account only "leaves" once a direct `getAccountInfo` confirms it
|
|
381
|
+
is closed or no longer has a watched authority. If the scan omitted it but it still exists (a
|
|
382
|
+
stale or partial RPC index), it stays watched and subscribed and the wallet's nonce check is
|
|
383
|
+
`unverified`, so the gap stays open. Every nonce notification is decoded locally. One that cannot
|
|
384
|
+
be decoded is reported (`nonce_notification_undecodable`) unless the next scan read that account
|
|
385
|
+
itself.
|
|
386
|
+
|
|
387
|
+
A coverage gap is never reported as clean. The `stream` check is `ok`, `partial` (some
|
|
388
|
+
subscriptions rejected) or `disconnected`. **`stream: ok` means the connection is alive and
|
|
389
|
+
the subscriptions were accepted; it does not prove notifications are flowing.** A connection
|
|
390
|
+
that stays up (answers pings) but silently stops forwarding notifications looks healthy, and
|
|
391
|
+
such a change is caught only by the next full re-sync scan. Freshness is therefore bounded by
|
|
392
|
+
`--resync-interval` (default 300 s), not by the stream.
|
|
393
|
+
|
|
394
|
+
**Structural limit: coverage is continuous only to the extent the provider delivers
|
|
395
|
+
notifications.** A change AND its revert that both land between two full scans, with both
|
|
396
|
+
notifications dropped by the provider while the socket stays alive, cannot be detected: each
|
|
397
|
+
scan sees the same state, and no notification arrived to say otherwise. nonce-watchtower never
|
|
398
|
+
claims complete continuous coverage. It claims: every full scan is checked for completeness and
|
|
399
|
+
freshness, every outage it can observe is reported, and nothing unverified is reported as clean. After reconnecting, `stream_gap` gives the window
|
|
400
|
+
(`before` = last frame received, `after` = re-sync time). It is raised only when the re-sync
|
|
401
|
+
scan ran every check. Otherwise `stream_gap_unresolved` is raised and the window stays open until
|
|
402
|
+
a complete scan. A restart of the process is reported the same way, from the state file's last
|
|
403
|
+
update.
|
|
404
|
+
|
|
405
|
+
### Running as a service
|
|
406
|
+
|
|
407
|
+
`examples/nonce-watchtower-stream.service` runs `stream` under systemd with a throwaway
|
|
408
|
+
`DynamicUser`, state in `/var/lib/nonce-watchtower`, secrets in a 0600 `EnvironmentFile`
|
|
409
|
+
(`examples/watchtower.env.example`), `Restart=always`, and a strict sandbox (read-only
|
|
410
|
+
filesystem, no new privileges, IP sockets only). Install steps are in the file's header. Logs go
|
|
411
|
+
to the journal (`journalctl -u nonce-watchtower-stream -f`).
|
|
412
|
+
|
|
413
|
+
### RPC
|
|
414
|
+
|
|
415
|
+
The default is `https://api.mainnet-beta.solana.com`. If an endpoint refuses or filters
|
|
416
|
+
System-program `getProgramAccounts`, the scan says so. It prints advice to set
|
|
417
|
+
`WATCHTOWER_RPC_URL` to a provider or your own node that serves it, and it marks nonce
|
|
418
|
+
coverage UNKNOWN. Passing `--rpc` with a key in it works, but it leaves the key in your
|
|
419
|
+
shell history.
|
|
420
|
+
|
|
421
|
+
## Development
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
.venv/bin/pip install 'setuptools>=77' # only for the clean-install test
|
|
425
|
+
.venv/bin/python -m unittest discover -s tests -t .
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Tests use JSON fixtures recorded from mainnet (see `tests/fixtures/README.md`) and never
|
|
429
|
+
touch the network. The WebSocket, Telegram and Discord endpoints are replaced by in-memory
|
|
430
|
+
fakes, and streaming tests run on a fake clock. `tests/test_packaging.py` builds the sdist,
|
|
431
|
+
builds the wheel from the unpacked sdist, installs it into a fresh venv with `--no-index`, and
|
|
432
|
+
runs `watchtower --help` plus an offline smoke test from outside the source tree. It is skipped
|
|
433
|
+
(and says so) if `setuptools>=77` is not installed.
|
|
434
|
+
|
|
435
|
+
## Verified layouts
|
|
436
|
+
|
|
437
|
+
- Squads v4 `Multisig` (Anchor/Borsh, decoded sequentially): 8-byte discriminator
|
|
438
|
+
`sha256("account:Multisig")[:8]`, `create_key`, `config_authority` (all-zero = autonomous),
|
|
439
|
+
`threshold` u16, `time_lock` u32, `transaction_index` u64, `stale_transaction_index` u64,
|
|
440
|
+
`rent_collector` `Option<Pubkey>` (1 tag byte, payload only when Some), `bump` u8,
|
|
441
|
+
`members` `Vec<(Pubkey, permissions u8)>` (Initiate 1, Vote 2, Execute 4). Multisig PDA seeds
|
|
442
|
+
`[b"multisig", b"multisig", create_key]`; vault PDA seeds `[b"multisig", multisig, b"vault", u8 index]`.
|
|
443
|
+
Source: [Squads-Protocol/v4](https://github.com/Squads-Protocol/v4) at commit `af94153f`,
|
|
444
|
+
[`state/multisig.rs`](https://github.com/Squads-Protocol/v4/blob/af94153ff77a28b6effe46b9c94baaa93742b48c/programs/squads_multisig_program/src/state/multisig.rs),
|
|
445
|
+
[`state/seeds.rs`](https://github.com/Squads-Protocol/v4/blob/af94153ff77a28b6effe46b9c94baaa93742b48c/programs/squads_multisig_program/src/state/seeds.rs),
|
|
446
|
+
[`instructions/multisig_create.rs`](https://github.com/Squads-Protocol/v4/blob/af94153ff77a28b6effe46b9c94baaa93742b48c/programs/squads_multisig_program/src/instructions/multisig_create.rs),
|
|
447
|
+
[`sdk/multisig/src/pda.ts`](https://github.com/Squads-Protocol/v4/blob/af94153ff77a28b6effe46b9c94baaa93742b48c/sdk/multisig/src/pda.ts).
|
|
448
|
+
Checked against mainnet: the decoded Exponent Finance multisig derives its own address, and its
|
|
449
|
+
vault 0 equals the on-chain upgrade authority of the Exponent program; the Manifest vault
|
|
450
|
+
published in its README (bump 253) is reproduced.
|
|
451
|
+
- PDA derivation: `sha256(seeds || bump || program_id || "ProgramDerivedAddress")`, rejected
|
|
452
|
+
when the result decompresses to an ed25519 point (same rule as curve25519-dalek). Checked
|
|
453
|
+
against real associated-token-account addresses.
|
|
454
|
+
- System instructions used for provenance (bincode u32 tag): `CreateAccount` = 0
|
|
455
|
+
(accounts: funder, new), `CreateAccountWithSeed` = 3, `InitializeNonceAccount` = 6
|
|
456
|
+
(accounts: nonce, …; data: authority).
|
|
457
|
+
|
|
458
|
+
- Nonce account (80 bytes, bincode): `Versions` u32 tag @0, `State` u32 tag @4,
|
|
459
|
+
**authority @8**, durable nonce @40, lamports_per_signature @72. See
|
|
460
|
+
`anza-xyz/solana-sdk` `nonce/src/state.rs` (`State::size() == 80`) and `versions.rs`.
|
|
461
|
+
- Upgradeable loader: `Program{tag=2, programdata@4}`,
|
|
462
|
+
`ProgramData{tag=3, slot@4, Option<authority> tag@12, authority@13}`.
|
|
463
|
+
|
|
464
|
+
## License
|
|
465
|
+
|
|
466
|
+
MIT
|