The Assay safety registry is deployed on Stellar testnet. It is not on pubnet, and nothing here should be read as a claim that it is ready to be.
| Registry contract | CBK4FBIHMDTXCUPE4E3ZDVSFJSCY5FJETTKNIQPN4LFJIKKIBLKIXQ73 |
| Network | Testnet (Test SDF Network ; September 2015) |
| Deployed | 2026-08-15 |
| Wasm hash | c4105b91b3ceae95b5a225c55bc6981b3dcf71d07fdd0ee5c79a21d25edd301b |
| Source | contracts/safety-registry |
| Admin / attester | GALIEUOBDLTFJHTVH5E3MT2BMDTQ3PKMX2U6BRXVKLEB7ARFORFNNMVY |
| Built with | stellar CLI 27.1.0, soroban-sdk 27.0.5, rustc 1.98.1 (pinned) |
The contracts' Rust toolchain is pinned to rustc 1.98.1 in
assay-contracts/rust-toolchain.toml,
so CI and local builds agree on the compiler and a toolchain upgrade is a
deliberate commit rather than whatever stable happens to be. Rebuilds should
be produced with it.
The worked integration example from integrating.md is deployed alongside it, wired to the registry above:
| Example gate contract | CAL5VYSWLKG367D5IYGI57XH7EMN5PLJ4CD6K3MO2HJBYYKEKPG3NKRX |
| Wasm hash | ca40172ec8ebc259e7e21429748b2bd7cafe467fa3005d849d81d16938e8987c |
| Redeployed | 2026-09-16 — severity ceiling, #26 |
| Source | contracts/example-gate |
| Step | Transaction |
|---|---|
| Upload registry wasm | 2c6b1e695fbaafa2c5134c6b57a0654371a8d9d7e90d70f3f85404a7709d53f1 |
| Deploy registry | ff11e24ff1d9ccd4aeea4dc258ce17d49a7b0d0c529a326f7eda9d6eff2f1c9c |
init(admin) |
14082d78211ad494406c14d6f0bd2993a88008cb4091457a7685b3794d20ee09 |
| Upload gate wasm (original) | 2dc8387ff2a4ef2a24f788de627f566dfc6d60d96806f7ebffb529324056233e |
| Deploy gate (original, superseded — admits DOGE) | a6c4f642af24f32ec116a8a8918153cacafb86f9ac33c73dfb942dce73d5897f |
| Upload gate wasm (redeploy) | 943c90b5d710499aa489b4ccb0c257447efb049859ef4401e10dc4e316e6d832 |
| Deploy gate (redeploy, #26 fix) | 2137605713924608aa801c560a9121534efd330201e6e174a33da51190e7bfa4 |
| Deploy gate (duplicate, unused) | 5fde49952a83f1acbe5b04a302302e0449f3348a49105d67babb8d1a5418b6c6 |
The last row is a mistake, recorded rather than hidden. Two working sessions
redeployed the same fixed wasm six minutes apart, so a second instance exists at
CCMA2SW23WUWTJGSC2MTUZVYWROMVTT42NLPTBCOAHGM632KEMNL5N3G, bound to the same
registry and running the same wasm (ca40172e…; it reused the upload above).
Soroban contracts cannot be deleted. It behaves identically and nothing refers
to it; CAL5VYSW… is the canonical instance.
Any of these can be read at
https://stellar.expert/explorer/testnet/tx/<hash>.
Three instances exist on testnet and none can be removed. Only one should be used. Checked on 2026-09-17 by fetching each contract's wasm and hashing it, and by reading the constructor argument from each deploy transaction:
| Instance | Status | Wasm | Registry at construction |
|---|---|---|---|
CAL5VYSWLKG367D5IYGI57XH7EMN5PLJ4CD6K3MO2HJBYYKEKPG3NKRX |
canonical — use this | ca40172e… (severity ceiling) |
CBK4FBIH… |
CCMA2SW23WUWTJGSC2MTUZVYWROMVTT42NLPTBCOAHGM632KEMNL5N3G |
duplicate, unused — nothing in the repo refers to it | ca40172e… (identical) |
CBK4FBIH… |
CANO57JRGTATHGLM26TWYPIXERSPVI5R52H33K7ZUJGGOEOVVZA44W3U |
superseded, do not use — admits critical-by-reputation assets (#26) | d1683a1e… (capability mask only) |
CBK4FBIH… |
Short answer: it cannot be verified today, and the reason is #127, not the source. The build is deterministic; the compiler is not pinned, so "the committed source" does not determine the bytes. What follows is the measurement rather than a conclusion drawn from one.
The Wasm hash rows above are a record of what was deployed. Whether the
source in this repository still produces them is a separate claim, and until
it is checked they are an unverified record.
Three builds of assay-safety-registry from the same commit, each after
cargo clean, the third from a different checkout path with a cold cargo
cache:
| Build | Toolchain | sha256sum of the built wasm |
|---|---|---|
| recorded 2026-08-15 | stellar 27.1.0, rustc unknown (never recorded) | c4105b91b3ceae95b5a225c55bc6981b3dcf71d07fdd0ee5c79a21d25edd301b |
| measured, same checkout | stellar 27.1.0, rustc 1.98.1 | 60d482546df4d1973f7d4b5d9ff5b413a974ba16afea1cf4851fe32fe0d6e745 |
| measured, other checkout path | stellar 27.1.0, rustc 1.98.1 | 60d482546df4d1973f7d4b5d9ff5b413a974ba16afea1cf4851fe32fe0d6e745 |
| measured, different compiler | stellar 27.1.0, rustc 1.95.0 | 12dbf6b9d29c2d1109523007b82392b91f232ca16cd1a2a8394b90d6c54425f6 |
Two things follow, and they are separate findings.
The build is deterministic. The same source under the same toolchain produces byte-identical wasm every time, including from a different checkout path with no build cache. Whatever the CLI and the compiler embed in the output, it is not a timestamp, a path, or anything else that varies between runs.
The compiler version alone changes the bytes. Identical source, identical
Cargo.lock, identical stellar CLI 27.1.0, identical target — rustc 1.98.1
and rustc 1.95.0 produce two different hashes. So byte-reproducibility across
machines is not a property this repository currently has, and cannot have
until the channel is fixed. assay-contracts/rust-toolchain.toml does not
exist; rustup selects whatever channel is installed, so two people running
make build-contract are running two different builds.
Neither measured hash equals the recorded one. That is not evidence that the source has drifted. It is the expected consequence of an unpinned toolchain, and it cuts both ways: today nobody can produce the recorded hash, and equally nobody can rule out that they would. Until #127 lands, the recorded hashes cannot be confirmed or refuted by anyone, and a matching hash found by accident would be luck rather than verification.
For the gate the same holds: recorded ca40172ec8ebc259e7e21429748b2bd7cafe467fa3005d849d81d16938e8987c,
built here 9bd4a4b6a5e71e6c201eb633218c65dfb5dc0209dac6a0d5471b0228a4200a00.
make verify-wasm # or: scripts/verify-wasm-source.shThe script builds each contract from the committed source and compares the
result to the hashes in this file. It builds into a temporary directory, so it
never disturbs assay-contracts/out/ or the artifact a deploy would upload,
and it never edits this document, changes a recorded hash, or submits a
transaction. It reports one of four outcomes per contract and exits 0 only
when every recorded hash is reproduced:
| Outcome | Meaning | Exit |
|---|---|---|
verified |
The fresh build hashes to the recorded value. | 0 |
mismatch |
It does not. Both values are printed, and it is never tolerated. | 1 |
unpinned |
rust-toolchain.toml is missing, so the build cannot be compared. |
2 |
unverifiable |
The stellar CLI is missing or is not the recorded 27.1.0, or the build failed. |
2 |
Exit 2 never means "verified" and never means "safe" — it means the run could
not answer the question, so a run with any unpinned or unverifiable
contract never exits 0. Today it exits 2, and that is the correct answer.
The moment #127 adds the pin this becomes the check it was written to be; the
unpinned branch is not a workaround, it is the absence of a verdict.
| Component | Recorded | Confidence |
|---|---|---|
stellar CLI |
27.1.0 | Recorded and reproducible |
| Rust target | wasm32v1-none |
Recorded and reproducible |
soroban-sdk |
27.0.5 | Recorded; pinned by Cargo.lock |
| Rust compiler | not recorded | Unpinned — see #127 |
The compiler row is the gap. One constraint worth recording for whoever
closes #127: soroban-sdk 27.0.5 requires rustc 1.91.0 or newer, while
stellar CLI 27.1.0 rejects anything outside 1.81, 1.82, 1.83 and 1.91.0 — and
rejects 1.91.0 itself, so 1.90.0 and 1.91.0 both fail, one for being too old
and one for the CLI's version check. A pin has to be a channel the CLI
accepts, not merely one the SDK allows.
The deployed bytes and this repository's source being correct is a
different question, and is not answered here. Nor does it check that the
registry still holds the attestations this document records — that is
scripts/check-deployment.sh, which compares the document against the chain
rather than against this repository.
Ten mainnet assets, spanning the severity range. Every number below was
produced by assay attestation from a live scan and submitted unmodified by
make attest; none was typed by hand. The full scan record, including the
eight further assets scanned but not attested, is in
attestation-run.md.
| Asset | Severity | Flags | Mechanics | Attest transaction | Preimage |
|---|---|---|---|---|---|
AQUA |
0 clear | 0 |
— | 1b6bafc1226570b2415299f5531256716f4d8dc489a9784fcd6ea347d0f63f5f |
v1 |
USDC |
2 medium | 18 |
auth_revocable, domain_unverified |
9869957e510ac6cb9ba4e5ffaff66ad0286e420e824efc0095d9e578be37564e |
v1 |
USDZ |
3 high | 6 |
auth_revocable, auth_clawback_enabled |
069d519fa20c472bbc3756291307fb30719219903c1e62a27b4967c0b07ecfd6 |
v1 |
BERKSHIRE |
4 critical | 54 |
auth_revocable, auth_clawback_enabled, domain_unverified, blocklisted |
322f4bb5fcc84cea9231b7d12e0a964a304a7bd4ea78f97991ca335426e5e2ff |
v1 |
ARST |
0 clear | 0 |
— | f497b91ab84340bcb7418940e620d080c8566e0cdf27346c8d74e165b5d66506 |
v1 |
USDGLO |
3 high | 6 |
auth_revocable, auth_clawback_enabled |
6016ed7d908cd20237f114b0cb6778886027ddc8336952acb77ddbeff16caf42 |
v1 |
DOGE |
4 critical | 48 |
domain_unverified, blocklisted |
ac1a89a64159e6ac2e9ed61bd67a79cd1584287cf57d19f9dc188d80e81298a6 |
v1 |
VELO |
0 clear | 16 |
domain_unverified |
dcae1dd810f3e310e71e006f175857b12a8925e173e6d62d9f023ee91582e85b |
v1 |
REPO |
4 critical | 48 |
domain_unverified, blocklisted |
a6b5fa3058c3f7f25cbf45b22603f721de7767e4b853e644932e72215eb940e0 |
v1 |
KALE |
0 clear | 16 |
domain_unverified |
db36e5ece6c52d01287da2bdb9722affab84047d58ec21a332edd9d1e7392c90 |
v1 |
Every row is v1 because all ten attestations predate the later encodings:
the first tranche was written 2026-08-15, the second 2026-09-05, and the third
plus the AQUA and DOGE re-attestations 2026-09-16 — all before check-set
binding (v2) landed on 2026-09-26 and network/scanner binding (v3) on
2026-09-27. The short names are the preimage's first line without the
assay-evidence- prefix: v1 is assay-evidence-v1, and so on for v2 and
v3. A verifier recomputing any of these hashes must render the v1
bytes described in contract-interface.md; the
aqua-mainnet-onchain vector under internal/attest/testdata/vectors/ pins
that encoding byte-for-byte to AQUA's on-chain hash. assay attestation -preimage-version v1 does that rendering from a fresh scan, and
scripts/reproducibility.sh reads this column and compares each asset under
the version recorded here, so an encoding change and an evidence change are
reported as different outcomes (ENCODING vs MISMATCH). A future
re-attestation under the current encoding would be v3, and this column is
what says so.
Issuers, SAC addresses, and the evidence hash each attestation commits to:
| Asset | Issuer | Testnet SAC address | evidence_hash |
Preimage |
|---|---|---|---|---|
AQUA |
GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA |
CDJF2JQINO7WRFXB2AAHLONFDPPI4M3W2UM5THGQQ7JMJDIEJYC4CMPG |
688453bd…61a4a9 |
v1 |
USDC |
GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN |
CA2E53VHFZ6YSWQIEIPBXJQGT6VW3VKWWZO555XKRQXYJ63GEBJJGHY7 |
e7fc765d…d790bd |
v1 |
USDZ |
GAKTLPC4ZV37SSCITQ5IS5AQ4WPF4CF4VZJQPPAROSGXMYOATF5U6XPR |
CAOM5NKBTSGEXTZZKH3STWSFWURMODC3TZ4NS2THN7W5YUDFK3IOHIHU |
ca9b13a6…65fb4d |
v1 |
BERKSHIRE |
GA22QHSHQEHDJS2ZOINSC77XPPQ24G5EFRJGVEIZLKC5FAW3PQ5XNSDQ |
CALAZXOC32XF2DFRKDOFQK7XQETZZTUK7C2OKC6YKPX6KY2YWU2YSPQW |
dc2bbf08…171a0d |
v1 |
ARST |
GCSAZVWXZKWS4XS223M5F54H2B6XPIIXZZGP7KEAIU6YSL5HDRGCI3DG |
CBARCMJYRRNSYCWCR3EU2PEHAHWHBCQSMIKQIUSDWR3BK7CBCP622Q2R |
82a6103f…fccacb |
v1 |
USDGLO |
GBBS25EGYQPGEZCGCFBKG4OAGFXU6DSOQBGTHELLJT3HZXZJ34HWS6XV |
CDGBOKCE25PVUKFWST2EEK52NHRS5WQ7TN26DFJYCEQNZNROQRSPIBQA |
69b8c4f8…7c27a5 |
v1 |
DOGE |
GA22IDJNHUMC3XKUCCBFNTQIJOUBWINC5GCXHLJ2V6KZ3OWAXCULNQ7P |
CDUV37BUTYKKWNGECZZNRYMM7JIQYYWAI7L2TPTXWQAEMIPG4SXRBRPD |
396c9f7c…91647e |
v1 |
VELO |
GDM4RQUQQUVSKQA7S6EM7XBZP3FCGH4Q7CL6TABQ7B2BEJ5ERARM2M5M |
CDHI6B6HBY2Q7BVGQ74C23GN3TKPGLCCMOSSRTEQDTTX4ELO5V6A6IOM |
3ed390fe…49e02a |
v1 |
REPO |
GA2BVQLGAG6UJDPHHTJDQAPBUYJV7D6IWIISGJI2LMQXTOKMHO36EAZR |
CBLZSCLVU3ZXRUJ3GEL7R3IP6CSN6VUEWPX7O4OYS3F3BYFUCJR7EEAA |
4dacaa0f…43cede |
v1 |
KALE |
GAKJM27QTNLBBZ352HQ4IDR3GWXUXQUEBKBDWOJG7RBH2NVEWUNSYCEF |
CCVNR6CGD6NFQG7XC6AVU5HF4YAPVXW5KZJCD73XFKUUO4YZUNEFKU2Z |
874087f2…dc6112 |
v1 |
USDZ is the case the severity model exists to handle: a
confiscation-capable issuer with a verified domain. It scores high on
capability anyway, because a verified domain does not weaken clawback — it only
names who holds it. BERKSHIRE is the escalation case: capability alone puts it
at high, and StellarExpert's blocklisting of nasdaq.finance raises it to
critical. Reputation moved it up; nothing in the pipeline can move an asset
down.
DOGE is the same escalation shown at its extreme, and is the more instructive
of the two: base severity 0, final severity 4. Its issuer holds no
authorization flags at all, so capability analysis honestly returns clear for
an asset the curated directory tags malicious. Everything separating it from
a harmless asset lives on the reputation axis.
These assets live on pubnet. Their testnet SAC addresses are what the registry is keyed on here, because a contract ID is derived from the network passphrase and so differs per network. Scanning reads pubnet, where the assets actually are; attesting writes to testnet, where the contract actually is. A pubnet deployment would key the same assets under their pubnet SAC addresses.
Scanner output and on-chain state, read back after the fact:
$ ./assay attestation -raw USDZ-GAKTLPC4ZV37SSCITQ5IS5AQ4WPF4CF4VZJQPPAROSGXMYOATF5U6XPR
3 6 ca9b13a66f3a0a4b43d66dea29a505658447e08eee200d2bdb5e66aa1065fb4d
$ make read ASSET=USDZ-GAKTLPC4ZV37SSCITQ5IS5AQ4WPF4CF4VZJQPPAROSGXMYOATF5U6XPR
{"attested_at":1786771651,"evidence_hash":"ca9b13a66f3a0a4b43d66dea29a505658447e08eee200d2bdb5e66aa1065fb4d","flags":6,"severity":3}
The severity, the bitset, and the evidence hash match, and the hash was recomputed from a fresh scan after the write — which is the point of leaving retrieval timestamps out of the preimage. A verifier who re-scans an asset whose sources have not changed reproduces the on-chain hash exactly. If it does not reproduce, either the asset changed or the attestation is not what it claims.
attested_at is the ledger timestamp of the write: 1786771651 is
2026-08-15T05:27:31Z.
Checked against the deployed contract before any attestation existed
(2026-08-15), again that day afterwards, and again on 2026-09-17, using native XLM as a control asset that is deliberately never
attested (SAC CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC):
| Call | Result |
|---|---|
get_safety(unattested) |
null |
is_safe(unattested, max_severity=4, max_age_secs=0) |
false |
is_safe(unattested, max_severity=0, max_age_secs=0) |
false |
The middle row is the one that matters: severity 4 is the maximum and age 0
disables the freshness check, so those are the most permissive arguments the
gate accepts. It still returns false, because there is nothing to admit. An
unknown asset is unknown, not safe.
is_safe across the attested set, read live (2026-08-15; re-read 2026-09-17 with
identical results — max_age_secs=0 disables freshness, so these do not expire):
| Asset | max_severity=0 |
max_severity=2 |
|---|---|---|
AQUA (0) |
true |
true |
USDC (2) |
false |
true |
USDZ (3) |
false |
false |
BERKSHIRE (4) |
false |
false |
| unattested | false |
false |
And through the deployed example gate, which is a real cross-contract call
rather than a direct read. The gate was redeployed on 2026-09-16 with the
severity ceiling that #26
tracked (deploy tx 21376057…7bfa4); the results below are against the new
instance. Assets whose attestations are older than the gate's 24-hour window
are refused as stale, which exercises that branch live as well:
| Asset | would_admit |
deposit |
|---|---|---|
KALE (clear, attested 2026-09-16) |
true |
succeeded, balance credited (e3d2825c8e5a6615236904643873f0f325329c8a112ae338272a34abeab2f592) |
AQUA (clear, re-attested 2026-09-16) |
true |
succeeded, balance credited (2cb3ce286c6f0874af9e8c86948c2cafad5ef4ef64edf055997595266d3da33f) |
REPO (critical, attested 2026-09-16) |
false |
Error(Contract, #4) — SeverityTooHigh |
DOGE (critical by reputation, re-attested 2026-09-16) |
false |
Error(Contract, #4) — SeverityTooHigh |
USDZ (clawback, attested 2026-08-15) |
false |
Error(Contract, #2) — AttestationStale |
unattested (native) |
false |
Error(Contract, #1) — NotAttested |
Refusals fail at simulation, so no transaction is submitted for them; the admit path is proven with submitted deposits whose balances read back. Every row depends on the gate's 24-hour freshness window, so these results were observed on 2026-09-16 and will not reproduce unchanged a day later without re-attestation.
The first run of this table found DOGE and AQUA refused as stale
(#2), because their attestations predated the freshness window — which
refused DOGE for the wrong reason and left the fix itself unobserved on the
asset that exposed the bug. So both were re-attested from live scans, and the old
and new instances were queried against the same fresh attestations:
| Re-attestation | Transaction | attested_at |
evidence_hash |
|---|---|---|---|
DOGE |
5012431be06a49f0bdc9b77e274aafaafabc6b30f6a5de535616050617ccd64c |
1789546342 |
396c9f7c…91647e (unchanged) |
AQUA |
595f89b53c897f583132e302ea6f78f1c334ce819423a27c5dd7981174fe39e7 |
1789546357 |
688453bd…61a4a9 (unchanged) |
Both hashes are byte-identical to the original attestations, so the refresh
changed only attested_at — the reproducibility property holding over a month
for AQUA.
| Gate | AQUA |
DOGE |
|---|---|---|
Old, CANO57JR… (capability mask only) |
admitted | admitted — the bug |
Fixed, CAL5VYSW… (mask + severity ceiling) |
admitted | refused, #4 SeverityTooHigh |
The old instance admitted DOGE because it masked capability bits only, and
DOGE's severity 4 comes entirely from reputation, which sets no capability bit.
The old instance is still deployed — contracts cannot be removed — and nothing in
these docs points to it any more.
Soroban persistent entries have a time-to-live measured in ledgers. When it runs out the entry is archived. What that means for Assay was checked against testnet rather than assumed, because the parameters are network configuration set by validator vote.
Read on 2026-09-27 with stellar network settings --network testnet
(protocol 28):
| Setting | Ledgers | At ~5 s per ledger |
|---|---|---|
min_persistent_ttl (what a fresh write gets) |
120 960 | ~7 days |
max_entry_ttl (the most an extension can grant) |
3 110 400 | ~180 days |
getLedgerEntries for five of the attestations (USDZ, USDC, KALE,
AQUA, DOGE) at ledger 4 900 421 returned each entry with
liveUntilLedgerSeq: 0. Their last writes were at ledgers 4 150 015 to
4 704 554, so each one's minimum TTL ran out long ago. Simulating a read showed
more than that: the contract instance and the contract code are archived
too. The simulated footprint lists all three under archived_soroban_entries.
The deployed contract predates any TTL handling, so nothing ever extended any
of it.
The obvious guess is that an archived attestation reads as None and gates
fail closed. That is not what happens. Since protocol 23 an archived
persistent entry is restored automatically when a transaction's footprint marks
it for restoration, and simulation adds that marking by itself:
$ stellar contract invoke --id CBK4FBIHMDTXCUPE4E3ZDVSFJSCY5FJETTKNIQPN4LFJIKKIBLKIXQ73 \
--source-account <any> --network testnet --send=no \
-- get_safety --asset CAOM5NKBTSGEXTZZKH3STWSFWURMODC3TZ4NS2THN7W5YUDFK3IOHIHU
{"attested_at":1786771651,"evidence_hash":"ca9b13a6…65fb4d","flags":6,"severity":3}
$ … -- is_safe --asset CAOM5NKB… --max_severity 4 --max_age_secs 0
true
$ … -- get_safety --asset CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC # native, never attested
null
That is USDZ, archived, returned with its original attested_at from
2026-08-15. The simulated read carried a minimum resource fee of 11 589 615
stroops (about 1.16 XLM), which is the price of restoring the entry, the
instance and the code. The SDK 27 test host behaves the same way, and
read_after_archival_returns_original_attestation in the contract tests pins
it.
So, for a consumer:
- Archived is distinguishable from never attested. Archived restores to
the stored attestation; never attested is
None. (Revoked isNone, the same as never attested; see contract-interface.md.) - Archival is not expiry. It never makes an attestation vanish, and it
never makes one read as fresh either: the original
attested_atsurvives. What protects a gate from a months-old attestation ismax_age_secs. A caller passingmax_age_secs = 0is served the restored attestation as-is. - Somebody pays to restore it. The first transaction that touches an archived entry pays the restore fee. Reading an archived attestation from inside a gate makes that gate's user pay it.
- Not verified live: a transaction submitted with a hand-built footprint that lists an archived entry read-only, without marking it for restoration, should fail rather than read. That follows from the protocol rules but was not exercised against testnet here.
From this change onward the contract extends TTLs itself:
| Call | Extends |
|---|---|
init |
contract instance and code, to the network maximum |
attest |
the attestation entry, plus the instance and code, to the network maximum |
attest_many |
every attestation entry in the batch, plus the instance and code, to the network maximum |
revoke |
the instance and code (the entry is removed, so it has no TTL) |
get_safety, is_safe, is_safe_masked |
nothing |
The maximum comes from the host (env.storage().max_ttl()), not from a
constant in the contract, so a change in network parameters needs no redeploy.
Why writes only:
- Retention follows the writer. Keeping an attestation live is the attester's decision, made by re-attesting. With extend-on-read, a popular asset's attestation would be kept alive by the gates reading it, whether or not anyone still stands behind it.
- Gates stay cheap and read-only. A TTL extension in a read path charges every consumer rent and adds a ledger write to what is otherwise a read.
- No maintenance entrypoint is needed. Anyone can extend or restore any
entry with the standard operations (
stellar contract extend,stellar contract restore). A contract entrypoint would add authorization surface and do nothing those operations cannot.
With this policy, an attestation written or refreshed today:
- Days 0 to ~180: live. It reads normally. Whether a gate accepts it is
the gate's
max_age_secscall, per freshness.md. Every recommended window there is 30 days or less, so for every gating use an attestation is stale long before it is archived. - After ~180 days without a re-attestation: archived. The next read
restores it, at the reader's cost, and it reads with its original
attested_at. Any gate with a window under 180 days refuses it as stale. - Re-attesting at any point pushes the TTL back out to the maximum. The
reattest_renews_ttltest pins that.
Keeping entries live is therefore a by-product of keeping them fresh. The
schedule that does that is the re-attestation loop designed in
attestation-writer.md, with the cadence set in
freshness.md. This document does not define a second schedule.
An asset dropped from that loop simply ages through the steps above. To make
an attestation unreadable on purpose, revoke it. Do not wait for archival,
because archival does not do that.
To inspect or extend one entry by hand:
KEY=$(echo '{"vec":[{"symbol":"Safety"},{"address":"<SAC>"}]}' | stellar xdr encode --type ScVal)
stellar contract extend --id <CONTRACT_ID> --key-xdr "$KEY" --durability persistent \
--ledgers-to-extend 3110399 --source-account assay-attester --network testnetThe tables in this file are the record of what is deployed, and they have drifted before: the docs named a superseded gate after the redeploy, and an unexplained duplicate instance existed before anything recorded it. Drift is silent, because a stale table reads exactly like a correct one.
scripts/check-deployment.sh reads these tables and the network, and reports
the difference:
scripts/check-deployment.sh # public testnet endpoint
scripts/check-deployment.sh --rpc https://… --timeout 30
ASSAY_RPC_URL=https://… ASSAY_RPC_TIMEOUT=30 scripts/check-deployment.sh
scripts/check-deployment.sh --deployment /tmp/copy.md # a copy, not this fileIt checks the registry and gate addresses, the wasm hash of the registry and of
all three gate instances, the registry each gate was constructed with, and the
severity, flags and evidence hash of all ten attestations — 21 checks. The
Preimage column is validated while the tables are parsed: every row must name
v1, v2 or v3, and the two tables must agree for a given asset. It is not
compared against the chain, because the chain stores only the hash; whether the
named encoding reproduces that hash is what the
Reproducibility job tests by
re-scanning. Chain
state is read with one getLedgerEntries call, as in
Entry lifetime above, so it needs no stellar CLI and no
transaction: restoring an archived entry is what a transaction would do, and
reading what is documented does not require it.
Every check gets one of four outcomes, and only the first is a pass:
| Outcome | Meaning |
|---|---|
valid |
The documented value matches chain state. |
invalid |
A mismatch, reported with both values. For an address, "no contract at this address" is the on-chain value. |
absent |
A documented attestation with no entry on chain — the expected outcome for an archived or withdrawn attestation. Distinct from a mismatch, and never a match. |
unknown |
The RPC was unreachable, timed out, or answered with an error. Inconclusive by definition: neither a match nor a mismatch, and never a pass. |
Exit codes follow scripts/reproducibility.sh: 0 if every check is valid,
1 if any is invalid or absent (drift, named, with both values), and
2 if no drift was found but at least one check is unknown, or the tables
could not be parsed. Exit 2 never means "no drift" — it means the run could not
answer the question, so a run with any unknown never exits 0. When drift and
an unknown occur together the run exits 1 and reports the unknowns too: the
finding is the more important information.
The script only reads. It never edits this file and never submits a transaction; fixing drift is a decision for whoever owns the deployment.
.github/workflows/check-deployment.yml
runs it weekly and on demand. It is deliberately not on the PR path, for the
reason given in CONTRIBUTING.md: a
change that touches no deployment state would fail here for reasons unrelated
to itself.
This checks the document against the chain, not the chain against the source. Whether the recorded wasm hashes come from the code in this repository is #93, which is still open and needs a reproducible build rather than a ledger read.
A rebuilt wasm only reproduces the recorded hashes if it comes from the same
toolchain. CONTRIBUTING.md names the
stellar CLI version, the Rust target and the soroban-sdk version, and gives
the command that verifies them before you build.
make build-contract # stellar contract build -> assay-contracts/out/
make deploy-testnet # upload + deploy, prints the new contract ID
stellar contract invoke --id <NEW_ID> --source-account assay-attester \
--network testnet -- init --admin $(stellar keys address assay-attester)Then update CONTRACT_ID in the Makefile and the addresses in
this file. init is single-shot; a second call fails with
AlreadyInitialized.
revoke and TTL-on-write ship in new contract code, and the registry at
CBK4FBIH… has no upgrade entrypoint, so it cannot pick them up. Moving to
them means a new deployment. Not done in the change that introduced them.
The live addresses above are still the old registry until someone with the
assay-attester key does this:
make deploy-testnet, theninitthe new ID as shown above.- Re-attest the ten assets from live scans with
make attest. Attestations cannot be copied across: the new contract has no import path, and re-scanning is the only way to write them with a truthfulattested_at. - Redeploy the example gate with the new registry address as its constructor argument. It binds the registry address at construction.
- Update
CONTRACT_IDin the Makefile, the tables in this file, integrating.md and contract-interface.md. - Leave the old registry in place and mark it superseded here, as was done for the old gate instances. Contracts cannot be deleted, and anyone still pointed at it keeps reading its archived attestations, restored on access.
Attesting and reading, once deployed:
make attest ASSET=AQUA-GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA
make read ASSET=AQUA-GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUAmake attest scans the asset live, derives the arguments with
assay attestation, and submits them. There is no path through it that lets a
hand-written severity reach the contract.
Admin authority can be rotated using a two-step process (transfer_admin followed by accept_admin).
Two-step transfer prevents accidental transfer to an unowned address or typo:
- Initiate Transfer (Current Admin):
stellar contract invoke --id <CONTRACT_ID> --source-account <CURRENT_ADMIN_KEY> \ --network testnet -- transfer_admin --new_admin <NEW_ADMIN_ADDRESS>
- Accept Transfer (New Admin):
stellar contract invoke --id <CONTRACT_ID> --source-account <NEW_ADMIN_KEY> \ --network testnet -- accept_admin
After accept_admin succeeds, new_admin becomes the sole authorized attester and the old admin key loses all write permissions.
Note
Redeploying contract code: Adding transfer_admin and accept_admin requires redeploying the registry contract on testnet because Soroban contract WASM is immutable without an explicit upgrade entrypoint. Redeploying requires running init(admin) on the newly deployed instance and re-submitting attestations for the corpus assets.
- Not mainnet, and not a candidate for it. The list below is why.
- One key can write anything. The admin is a single ed25519 account whose seed lives on one machine. Anyone holding it can attest any severity for any asset. Custody, backup, loss and compromise response, and why rotation currently means redeploy are documented in attester-key.md. A real deployment wants a threshold of independent attesters; multi-attestor.md compares the options.
- 10 attested assets. Everything else on the network reads as
None. That is the correct answer — unknown, not safe — but it means the registry is not useful as a general lookup yet. - No re-attestation schedule. These attestations are as fresh as the
attested_atin the table and nothing is refreshing them. A caller must pass amax_age_secsit is actually willing to accept rather than trusting that someone is keeping the registry current. freshness.md has the measured flag-change rates behind that advice. - Testnet data is not durable. Testnet is periodically reset, which removes everything. Separately, this deployment predates TTL handling, so its instance, code and attestations are all archived today. They are restored on access at the caller's expense, not lost. See Entry lifetime.