From e9d90a9560630a130c39eebb058303af9bb4b955 Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Wed, 7 Oct 2026 05:49:42 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A7=AE=20Add=20optional=20Soroban=20p?= =?UTF-8?q?roof=20anchoring\n\nAdd=20a=20minimal=20Soroban=20event=20contr?= =?UTF-8?q?act=20and=20an=20opt-in=20mainnet=20relayer=20flow=20that=20anc?= =?UTF-8?q?hors=20a=20deterministic=20digest=20of=20the=20finalized=20SEP-?= =?UTF-8?q?53=20proof=20while=20keeping=20document=20data=20off-chain.\n\n?= =?UTF-8?q?Co-authored-by:=20Codex=20Web=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 ++ Cargo.toml | 16 ++++++ README.md | 48 ++++++++++++++++-- contracts/proof-anchor/Cargo.toml | 15 ++++++ contracts/proof-anchor/src/lib.rs | 47 +++++++++++++++++ src/client/App.tsx | 50 +++++++++++++++++- src/server/index.ts | 31 ++++++++++++ src/server/soroban.ts | 84 +++++++++++++++++++++++++++++++ src/server/store.ts | 3 +- src/shared/types.ts | 10 ++++ 10 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 Cargo.toml create mode 100644 contracts/proof-anchor/Cargo.toml create mode 100644 contracts/proof-anchor/src/lib.rs create mode 100644 src/server/soroban.ts diff --git a/.env.example b/.env.example index bd30307..b5d4fb0 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,6 @@ DATA_DIR=./data MAX_UPLOAD_MB=25 HORIZON_URL=https://horizon.stellar.org # PUBLIC_BASE_URL=https://sign.example.org +SOROBAN_RPC_URL=https://mainnet.sorobanrpc.com +# SOROBAN_ANCHOR_CONTRACT_ID=C... +# SOROBAN_ANCHOR_SECRET=S... diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5f10951 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] +resolver = "2" +members = ["contracts/proof-anchor"] + +[workspace.dependencies] +soroban-sdk = "26" + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/README.md b/README.md index d547d4d..9aade49 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A full-stack document signer built around [Stellar SEP-53](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md). -The app is deliberately off-chain: there is no Stellar transaction and the server never receives a secret key. Users choose a wallet through Stellar Wallets Kit, review the PDF, and sign one canonical message. The server then checks the actual wallet public key, verifies the SEP-53 signature, and confirms the key's signer authority for the represented account through SDF Horizon before publishing a share URL. +The document-signing path is deliberately off-chain: users choose a wallet through Stellar Wallets Kit, review the PDF, and sign one canonical message without submitting a Stellar transaction or exposing their secret key. The server then checks the actual wallet public key, verifies the SEP-53 signature, and confirms the key's signer authority for the represented account through SDF Horizon before publishing a share URL. Deployments can optionally offer a Soroban mainnet anchor that commits only a digest of the finalized proof. ## Signing flow @@ -13,7 +13,8 @@ The app is deliberately off-chain: there is no Stellar transaction and the serve 5. **Review the identity.** A display name and optional PNG/JPEG/WebP visual insignia can be attached. 6. **Sign.** Immediately before signing, the app fetches the wallet address again. After SEP-53 signing, it checks any signer address returned by the wallet against the key that was reviewed. 7. **Recheck on the server.** The server verifies the signature and file hashes, reloads the Horizon account, and rejects the request if signer weights, thresholds, or the account-state ledger changed after review. -8. **Share the proof.** The public page shows the PDF and a signer-authority badge such as **“Medium-weight signer for GAAAA…BBBB”**. The raw signing key remains in the verification details instead of being the primary identity label. +8. **Optionally anchor it.** When configured, the signer can request a relayed Soroban mainnet transaction containing only a SHA-256 digest of the canonical SEP-53 message plus signature. +9. **Share the proof.** The public page shows the PDF and signer-authority badge. Anchored proofs also expose their mainnet transaction, ledger, contract, and digest. ## Signed format @@ -48,6 +49,47 @@ https://horizon.stellar.org Override it with `HORIZON_URL` if the deployment uses another Horizon service. +## Optional Soroban mainnet anchor + +A minimal event-only contract lives in `contracts/proof-anchor`. It accepts one `BytesN<32>` proof digest, emits it with the ledger sequence and ledger timestamp, and stores no PDF or identity data. + +The digest is: + +~~~text +SHA-256( + "Stellar PDF Soroban Anchor v1\n" + || canonical SEP-53 message + || "sep53-signature:" + || base64 SEP-53 signature + || "\n" +) +~~~ + +Build and deploy it: + +~~~sh +stellar contract build --package proof-anchor + +stellar network add signer-mainnet \ + --rpc-url https://mainnet.sorobanrpc.com \ + --network-passphrase "Public Global Stellar Network ; September 2015" + +stellar contract deploy \ + --wasm target/wasm32v1-none/release/proof_anchor.wasm \ + --source-account \ + --network signer-mainnet +~~~ + +Configure the deployment with a dedicated, conservatively funded relayer account: + +~~~text +SOROBAN_RPC_URL=https://mainnet.sorobanrpc.com +SOROBAN_ANCHOR_CONTRACT_ID=C... +SOROBAN_ANCHOR_SECRET=S... +~~~ + +The relayer secret belongs to the deployment, never the document signer. Without both anchor variables the existing SEP-53 flow stays fully off-chain and the anchor option is hidden. + ## Run locally Requires Node 22 or newer. @@ -101,6 +143,6 @@ HORIZON_URL=https://horizon.stellar.org “Medium-weight signer,” “high-weight signer,” and similar labels describe the signer weight relative to the represented account's threshold configuration at the Horizon account-state ledger captured in the signed message. The signer configuration can change later; the proof preserves what was checked when the signature was created. -Likewise, `signedAt` is cryptographically protected but is still a timestamp claim made by the signer. `serverReceivedAt` records when this server accepted the valid proof. Neither is a trusted timestamp authority. +Likewise, `signedAt` is cryptographically protected but is still a timestamp claim made by the signer. `serverReceivedAt` records when this server accepted the valid proof. Neither is a trusted timestamp authority. A Soroban anchor independently establishes that the proof digest was committed by the mainnet ledger containing its transaction, but it is not a conventional timestamp-authority certificate. See [SECURITY.md](./SECURITY.md) for the full trust model. diff --git a/contracts/proof-anchor/Cargo.toml b/contracts/proof-anchor/Cargo.toml new file mode 100644 index 0000000..3eace5c --- /dev/null +++ b/contracts/proof-anchor/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "proof-anchor" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/proof-anchor/src/lib.rs b/contracts/proof-anchor/src/lib.rs new file mode 100644 index 0000000..e6bca3f --- /dev/null +++ b/contracts/proof-anchor/src/lib.rs @@ -0,0 +1,47 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, BytesN, Env}; + +#[contracttype] +#[derive(Clone, Eq, PartialEq)] +pub struct AnchorReceipt { + pub proof_hash: BytesN<32>, + pub ledger: u32, + pub ledger_timestamp: u64, +} + +#[contract] +pub struct ProofAnchor; + +#[contractimpl] +impl ProofAnchor { + pub fn anchor(env: Env, proof_hash: BytesN<32>) -> AnchorReceipt { + let receipt = AnchorReceipt { + proof_hash, + ledger: env.ledger().sequence(), + ledger_timestamp: env.ledger().timestamp(), + }; + env.events().publish((symbol_short!("proof"),), receipt.clone()); + receipt + } +} + +#[cfg(test)] +mod test { + use super::{ProofAnchor, ProofAnchorClient}; + use soroban_sdk::{BytesN, Env}; + + #[test] + fn anchors_the_exact_digest() { + let env = Env::default(); + let contract_id = env.register(ProofAnchor, ()); + let client = ProofAnchorClient::new(&env, &contract_id); + let digest = BytesN::from_array(&env, &[7; 32]); + + let receipt = client.anchor(&digest); + + assert_eq!(receipt.proof_hash, digest); + assert_eq!(receipt.ledger, env.ledger().sequence()); + assert_eq!(receipt.ledger_timestamp, env.ledger().timestamp()); + } +} diff --git a/src/client/App.tsx b/src/client/App.tsx index 19d95eb..aec4a95 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -56,9 +56,18 @@ function HomePage() { const [busy, setBusy] = useState(false); const [status, setStatus] = useState(""); const [error, setError] = useState(""); + const [anchorEnabled, setAnchorEnabled] = useState(false); + const [anchorOnMainnet, setAnchorOnMainnet] = useState(false); useEffect(() => { ensureWalletKit(); + void fetch("/api/config") + .then(async (response) => { + if (!response.ok) throw new Error("Unable to load deployment configuration."); + return response.json() as Promise<{ sorobanAnchorEnabled?: boolean }>; + }) + .then((config) => setAnchorEnabled(Boolean(config.sorobanAnchorEnabled))) + .catch(() => setAnchorEnabled(false)); }, []); useEffect(() => { @@ -242,10 +251,15 @@ function HomePage() { ); } - setStatus("Checking the returned public key and publishing the proof…"); + setStatus( + anchorOnMainnet + ? "Publishing the proof and anchoring its digest on Stellar mainnet…" + : "Checking the returned public key and publishing the proof…", + ); const form = new FormData(); form.set("manifest", JSON.stringify(manifest)); form.set("signature", result.signedMessage); + form.set("anchor", anchorOnMainnet ? "true" : "false"); form.set("document", document); if (insignia) form.set("insignia", insignia); @@ -418,6 +432,23 @@ function HomePage() { {error &&
{error}
} {status &&
{status}
} + {anchorEnabled && ( + + )} +