//! Reading anchors from Solana, on core v2: seals of the journal and records
//! of the status section. Item P-6 of the MVP plan; spec v2 §7.3.
//!
//! # The status memo: open
//!
//! A status record is anchored as the memo of an ordinary transaction (the
//! SPL Memo program). The memo is one line:
//!
//! ```text
//! ksg:st:v1
//! link = SHA-256("ksg:st:link:v1" ‖ prev) prev = the record's own `prev`
//! commit = SHA-256("ksg:st:commit:v1" ‖ record hash)
//! ```
//!
//! Open on purpose: the status section is the one part of a container that is
//! **read** (KS-7 §7.2-octies — a buyer must see the state of the rights), and
//! its records are public by construction. Two memos with one `link` and
//! different `commit` are two status records after one head: a rewritten
//! history of the container's status, visible to anyone who holds the
//! section ([`find_status_forks`]).
//!
//! # The seal memo: blind
//!
//! A seal of the journal under the movable seal (CH-7) is anchored with a
//! **blind** memo, of fixed length:
//!
//! ```text
//! ksg:bs:v1
//! link = PRF(scan_key, "ksg:bs:link:v1" ‖ prev seal) 16 bytes, hex
//! commit = PRF(scan_key, "ksg:bs:commit:v1" ‖ seal) 16 bytes, hex
//! ```
//!
//! `[decision] 13.09` (`Resheniya_13.09_dostup_k_tsepi.md` §2): an anonymous
//! observer sees that an anchor exists and nothing else. An open memo, as
//! the status memo above, does not keep that: whoever learns one seal's hash walks the chain
//! memo by memo. Under the blind memo the walk needs the holder's `scan_key`,
//! and the holder decides who gets it — the auditor, with the export. The
//! price is the one `ksg-anchor::blind` names: a fork is visible only to
//! whoever holds the key. The PRF is the one of `ksg-anchor::blind`
//! (HKDF-SHA256, fixed public salt); it is here, in the open crate, because a
//! verifier must be able to recompute it without the proprietary one.
//!
//! What stays visible: the paying account. One payer's history groups its
//! memos — blinding the content does not hide who paid (limit, not gap: the
//! holder pays the gas, `[decision]` 19.09, CT-04).
//!
//! The scan key changes by period ([`SealScanKey::for_period`]): each seal
//! is blinded under the key of its month, derived from the holder's master
//! key. A key handed to an auditor reads its period's seals and no other's;
//! the reader takes a set of keys ([`SolanaReader::with_scan_keys`]) and a key
//! file holds one key or the keys of named periods ([`scan_keys_from_text`]).
//!
//! # What the reader establishes, and what it does not
//!
//! The finalized slot is a consensus quantity; `blockTime` is a stake-weighted
//! **estimate** of wall time, at one-second grain. The reader returns the
//! estimate as the anchoring moment because a seal or a status needs a moment, and says so
//! (`ksg-anchor-v2` writes what this reads).
//!
//! The cluster is the verifier's, not the anchor's, and it is checked against
//! the node itself: a node claiming to be mainnet is asked for its genesis hash.
//!
//! # Forks
//!
//! [`find_status_forks`] and [`find_bound_forks`] look for competing memos
//! among the transactions of the accounts that paid for the export's anchors.
//! A fork paid for by another account is not found this way; see the limits
//! in the module of the writer.
use std::collections::BTreeMap;
use ksg_core_v2::anchor::{Attestation, AttestationVerifier};
use ksg_core_v2::crypto::hash::Hash;
use ksg_core_v2::doc::{Timestamp, Uri};
use ksg_core_v2::error::Invalid;
use ksg_core_v2::journal::{BoundExport, RecordReport};
use ksg_core_v2::section::StatusExport;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// The anchor kind of a status record anchored on Solana with an open memo.
pub const STATUS_KIND: &str = "urn:ksg:anchor:solana:status:v1";
/// The anchor kind of a seal anchored on Solana with a blind memo.
pub const SEAL_KIND: &str = "urn:ksg:anchor:solana:seal:v1";
/// The memo prefix of a blind seal anchor.
pub const SEAL_MEMO_TAG: &str = "ksg:bs:v1";
/// The width of a blind value, bytes (as `ksg-anchor::blind`).
pub const SEAL_BLIND_LEN: usize = 16;
/// The salt of the extraction step: fixed and public, the secret is the key.
const SEAL_SALT: &[u8] = b"ksg:bs:v1";
/// The domain of a period key's derivation.
const PERIOD_INFO: &[u8] = b"ksg:bs:period:v1";
/// The scan keys of a file: one key (64 hex digits — a container whose key
/// never changes), or the keys of named periods,
/// `{"periods": {"2026-10": "", …}}`.
///
/// # Errors
///
/// Neither form, or a key that is not 32 bytes of hex.
pub fn scan_keys_from_text(s: &str) -> Result, String> {
let t = s.trim();
if !t.starts_with('{') {
return SealScanKey::from_hex(t).map(|k| vec![k]);
}
let v: Value = serde_json::from_str(t).map_err(|e| format!("scan keys: {e}"))?;
let periods = v
.get("periods")
.and_then(Value::as_object)
.ok_or("scan keys: no \"periods\"")?;
if periods.is_empty() {
return Err("scan keys: no period".into());
}
periods
.values()
.map(|k| {
k.as_str()
.ok_or_else(|| "scan keys: a key is not text".to_owned())
.and_then(SealScanKey::from_hex)
})
.collect()
}
/// The holder's scanning key for the seals of one container: the right to
/// recognize its anchors and to see a fork among them.
///
/// Compares in constant time, prints nothing, is wiped on drop — as
/// `ksg-anchor::blind::ScanKey`.
#[derive(Clone, Eq, zeroize::ZeroizeOnDrop)]
pub struct SealScanKey([u8; 32]);
impl PartialEq for SealScanKey {
fn eq(&self, other: &Self) -> bool {
let mut d = 0u8;
for (a, b) in self.0.iter().zip(other.0.iter()) {
d |= a ^ b;
}
d == 0
}
}
impl core::fmt::Debug for SealScanKey {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("SealScanKey(…)")
}
}
impl SealScanKey {
/// A key from 32 bytes.
#[must_use]
pub const fn new(bytes: [u8; 32]) -> Self {
Self(bytes)
}
/// The key from its file: 64 hex characters, whitespace around ignored.
///
/// # Errors
///
/// Not 32 bytes of hex.
pub fn from_hex(s: &str) -> Result {
let v = zeroize::Zeroizing::new(
hex::decode(s.trim()).map_err(|_| "scan key: not hex".to_owned())?,
);
let b: [u8; 32] = v
.as_slice()
.try_into()
.map_err(|_| "scan key: not 32 bytes".to_owned())?;
Ok(Self(b))
}
/// The key as its file holds it.
#[must_use]
pub fn to_hex(&self) -> zeroize::Zeroizing {
zeroize::Zeroizing::new(hex::encode(self.0))
}
fn prf(&self, domain: &[u8], h: &Hash) -> [u8; SEAL_BLIND_LEN] {
let mut info = Vec::with_capacity(domain.len() + 32);
info.extend_from_slice(domain);
info.extend_from_slice(h.as_bytes());
let mut out = [0u8; SEAL_BLIND_LEN];
hkdf::Hkdf::::new(Some(SEAL_SALT), &self.0)
.expand(&info, &mut out)
.expect("16 bytes is within HKDF-SHA256's output");
out
}
/// The key of one period, derived from a holder's master key: what the
/// holder hands an auditor for that period alone. A key given out links
/// its period's seals forever; it says nothing of another period's
/// (KS-1 §14.3: rotation closes the future, never the past).
///
/// `period` is a label — the reader uses the calendar month, `YYYY-MM`.
#[must_use]
pub fn for_period(&self, period: &str) -> Self {
let mut info = Vec::with_capacity(PERIOD_INFO.len() + 8 + period.len());
info.extend_from_slice(PERIOD_INFO);
info.extend_from_slice(&(period.len() as u64).to_be_bytes());
info.extend_from_slice(period.as_bytes());
let mut out = [0u8; 32];
hkdf::Hkdf::::new(Some(SEAL_SALT), &self.0)
.expand(&info, &mut out)
.expect("32 bytes is within HKDF-SHA256's output");
Self(out)
}
/// The blind `link` of a seal whose predecessor is `prev`.
#[must_use]
pub fn link(&self, prev: &Hash) -> [u8; SEAL_BLIND_LEN] {
self.prf(b"ksg:bs:link:v1", prev)
}
/// The blind `commit` of `seal`.
#[must_use]
pub fn commit(&self, seal: &Hash) -> [u8; SEAL_BLIND_LEN] {
self.prf(b"ksg:bs:commit:v1", seal)
}
}
/// A blind seal memo.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SealMemo {
/// See the module note.
pub link: [u8; SEAL_BLIND_LEN],
/// See the module note.
pub commit: [u8; SEAL_BLIND_LEN],
}
impl SealMemo {
/// The memo of `seal`, whose predecessor is `prev` (the container's
/// identifier for the first).
#[must_use]
pub fn of(key: &SealScanKey, prev: &Hash, seal: &Hash) -> Self {
Self {
link: key.link(prev),
commit: key.commit(seal),
}
}
/// Parses a memo; `None` for anything else, which is normal on a public
/// chain — and for a memo of another width, which is not ours.
#[must_use]
pub fn parse(s: &str) -> Option {
let mut parts = s.split_whitespace();
if parts.next() != Some(SEAL_MEMO_TAG) {
return None;
}
let f = |p: Option<&str>| -> Option<[u8; SEAL_BLIND_LEN]> {
hex::decode(p?).ok()?.try_into().ok()
};
let link = f(parts.next())?;
let commit = f(parts.next())?;
if parts.next().is_some() {
return None;
}
Some(Self { link, commit })
}
}
impl core::fmt::Display for SealMemo {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"{SEAL_MEMO_TAG} {} {}",
hex::encode(self.link),
hex::encode(self.commit)
)
}
}
/// The memo prefix of a status anchor.
pub const STATUS_MEMO_TAG: &str = "ksg:st:v1";
/// The SPL Memo program, version 2.
pub const MEMO_PROGRAM: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
/// Genesis hashes of the public clusters: how a node proves which one it is.
pub const GENESIS: [(&str, &str); 3] = [
(
"mainnet-beta",
"5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
),
("devnet", "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG"),
("testnet", "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY"),
];
/// A single local node (`solana-test-validator`): a real Solana runtime that
/// nobody else sees. Its genesis is new on every start, so it cannot be
/// pinned; it is accepted only when named, and only if the node is **not**
/// one of the public clusters. An anchor on it proves that the transaction is
/// a valid Solana transaction and nothing more: no third party can see it,
/// and the node's history ends when the node is deleted.
pub const LOCALNET: &str = "localnet";
/// `link` of a status record whose `prev` is `prev`.
#[must_use]
pub fn status_link(prev: &Hash) -> Hash {
Hash::sha256_parts(&[b"ksg:st:link:v1", prev.as_bytes()])
}
/// `commit` of a status record whose hash is `record`.
#[must_use]
pub fn status_commit(record: &Hash) -> Hash {
Hash::sha256_parts(&[b"ksg:st:commit:v1", record.as_bytes()])
}
/// A status memo.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StatusMemo {
/// See the module note.
pub link: Hash,
/// See the module note.
pub commit: Hash,
}
impl StatusMemo {
/// The memo of a status record with this `prev` and this hash.
#[must_use]
pub fn of(prev: &Hash, record: &Hash) -> Self {
Self {
link: status_link(prev),
commit: status_commit(record),
}
}
/// Parses a memo; `None` for anybody else's memo, which is normal on a
/// public chain.
#[must_use]
pub fn parse(s: &str) -> Option {
let mut parts = s.split_whitespace();
if parts.next() != Some(STATUS_MEMO_TAG) {
return None;
}
let link = Hash::from_multihash(parts.next()?).ok()?;
let commit = Hash::from_multihash(parts.next()?).ok()?;
if parts.next().is_some() {
return None;
}
Some(Self { link, commit })
}
}
impl core::fmt::Display for StatusMemo {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"{STATUS_MEMO_TAG} {} {}",
self.link.to_multihash(),
self.commit.to_multihash()
)
}
}
/// Two memos after one head, of different records: a fork of the status
/// history.
#[must_use]
pub fn is_status_fork(a: &StatusMemo, b: &StatusMemo) -> bool {
a.link == b.link && a.commit != b.commit
}
/// What `Attestation.proof` holds for a Solana anchor — a seal's or a status
/// record's: canonical JSON.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AnchorProof {
/// The transaction signature, base58.
pub signature: String,
/// The slot the transaction landed in.
pub slot: u64,
/// The cluster: `mainnet-beta`, `devnet`, `testnet`.
pub cluster: String,
}
impl AnchorProof {
/// The proof as the bytes of `Attestation.proof`.
///
/// # Errors
///
/// Never in practice: three plain fields canonicalize.
pub fn to_bytes(&self) -> Result, Invalid> {
let v = serde_json::to_value(self).map_err(|_| Invalid::Schema("anchor proof"))?;
ksg_core_v2::canonical::canonicalize(&v)
}
/// The proof from `Attestation.proof`.
///
/// # Errors
///
/// The bytes are not an anchor proof.
pub fn from_bytes(b: &[u8]) -> Result {
serde_json::from_slice(b).map_err(|_| Invalid::Schema("anchor proof: does not parse"))
}
}
/// A JSON-RPC endpoint of a Solana node. A trait for the reason the anchoring
/// profile gives: the verifier chooses how it reaches the network.
pub trait Rpc {
/// Calls `method` with `params`; returns `result`.
///
/// # Errors
///
/// Transport failure or an RPC error object, as text.
fn call(&self, method: &str, params: Value) -> Result;
}
/// A transaction as the reader needs it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tx {
/// The slot.
pub slot: u64,
/// The block time estimate, Unix seconds.
pub block_time: Option,
/// Who paid: the first account key.
pub fee_payer: String,
/// Every memo of the SPL Memo program in the transaction, as text.
pub memos: Vec,
}
fn err(s: &'static str) -> Invalid {
Invalid::Schema(s)
}
/// Fetches a **finalized**, **successful** transaction. `Ok(None)` when the
/// node has none: not yet finalized, forged, or beyond the node's history —
/// three cases the reader cannot tell apart and does not try to.
///
/// # Errors
///
/// Transport, or a response of an unexpected shape.
pub fn get_transaction(rpc: &dyn Rpc, signature: &str) -> Result