diff --git a/.cursor/rules/00-project.mdc b/.cursor/rules/00-project.mdc
deleted file mode 100644
index 4c5ba03..0000000
--- a/.cursor/rules/00-project.mdc
+++ /dev/null
@@ -1,14 +0,0 @@
----
-description: Project-wide NO SUS Monad experiment rules and handover map.
-alwaysApply: true
----
-
-Read `AGENTS.md` and `docs/STATUS.md` before changing code. This is a separate, testnet-only NO SUS
-experiment; do not read, edit, deploy, or reuse secrets from the production NO SUS repository.
-
-Preserve the threshold-custody honesty boundary: no plaintext, key, salt, filename, private key, or
-service-role key may reach the contract, client logs, or public environment. Do not claim a human read,
-legal proof, screenshot prevention, or independently verifiable deletion.
-
-Use focused checks in normal work. Ask the exact finalization question from `AGENTS.md` after every
-substantive file-changing response.
diff --git a/.cursor/rules/10-contracts.mdc b/.cursor/rules/10-contracts.mdc
deleted file mode 100644
index 9efbd0d..0000000
--- a/.cursor/rules/10-contracts.mdc
+++ /dev/null
@@ -1,9 +0,0 @@
----
-description: Solidity contract scope and receipt invariants.
-globs: contracts/**
-alwaysApply: false
----
-
-The contract records a ciphertext digest and wallet acknowledgement only. Never add plaintext, keys,
-salts, filenames, payment logic, admin bypasses, or an on-chain key-proof function. Preserve one open,
-recipient, and expiry invariants. Add a failing test before every contract behavior change.
diff --git a/.cursor/rules/20-web.mdc b/.cursor/rules/20-web.mdc
deleted file mode 100644
index b190ba6..0000000
--- a/.cursor/rules/20-web.mdc
+++ /dev/null
@@ -1,9 +0,0 @@
----
-description: Web experience and client security rules.
-globs: web/**
-alwaysApply: false
----
-
-Keep browser crypto and wallet actions in client components; keep Supabase service-role access in server
-route handlers only. Show explicit testnet and experimental labels. Dynamic wallet and receipt state must
-be accessible, keyboard usable, and respect reduced-motion preferences.
diff --git a/.env.example b/.env.example
index 5c580a0..ef8a342 100644
--- a/.env.example
+++ b/.env.example
@@ -1,19 +1,28 @@
-# Public browser configuration — safe to expose in the client bundle.
-NEXT_PUBLIC_MONAD_RPC_URL=https://rpc.testnet.monad.xyz
-NEXT_PUBLIC_MONAD_EXPLORER_URL=https://testnet.monadscan.com
-NEXT_PUBLIC_DROPS_CONTRACT_ADDRESS=
-NEXT_PUBLIC_SUPABASE_URL=
-NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
+# NO SUS Canary: every secret and setting, with no values.
+# Nothing here is needed to run the app locally (it uses the hosted devnet backend).
+# Real values go in Supabase function secrets (`supabase secrets set ...`) or a git-ignored
+# .env file. Never commit a filled-in copy. See docs/SELF_HOSTING.md.
-# Server-only values — never prefix these with NEXT_PUBLIC_.
-SUPABASE_SERVICE_ROLE_KEY=
-LIT_NETWORK=naga-dev
-LIT_ACCOUNT_API_KEY=
-DEPLOYER_PRIVATE_KEY=
-CONFIRM_MONAD_DEPLOY=
+# --- Relayer: Supabase Edge Function `canary` (function secrets) ---
+# 64 hex chars, random (openssl rand -hex 32). Salts the anonymous reader tag. Never share it.
+CANARY_TAG_SALT=
+# Devnet relayer keypairs, comma-separated: base58 64-byte secret keys or solana-keygen JSON
+# arrays. Fund them at https://faucet.solana.com. Devnet only.
+SOLANA_RELAYER_KEYS=
+# Optional. Default https://api.devnet.solana.com
+SOLANA_RPC_URL=
+# Optional. Default MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr (SPL Memo)
+SOLANA_MEMO_PROGRAM=
+# Leave unset. Setting it turns on a legacy prototype path the product does not use.
+CANARY_CONTRACT_ADDRESS=
+# SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are injected by Supabase. Never set them by hand
+# and never put the service-role key in the app.
-# Gate T1 live runner only (tool/threshold-spike). Never put these in NEXT_PUBLIC_*.
-# DROPS_CONTRACT_ADDRESS=
-# RECIPIENT_PRIVATE_KEY=
-# STRANGER_PRIVATE_KEY=
-# THRESHOLD_SPIKE_RUN=
+# --- Flutter build (--dart-define, public by design) ---
+# true = the standalone Canary web app that is deployed.
+NOSUS_CANARY_ONLY=true
+# Repo link shown in the app.
+CANARY_REPO_URL=https://github.com/https-shubhamsahu/nosus-canary
+
+# --- tool/solana-spike (optional, local only) ---
+# The spike writes its own devnet key to tool/solana-spike/.keys/ (git-ignored).
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 473051f..6383c44 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,8 +1 @@
-# Replace the handle if the GitHub repository uses a different owner account.
* @https-shubhamsahu
-
-/contracts/ @https-shubhamsahu
-/web/ @https-shubhamsahu
-/supabase/ @https-shubhamsahu
-/.github/ @https-shubhamsahu
-/docs/ @https-shubhamsahu
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
deleted file mode 100644
index d91d95d..0000000
--- a/.github/workflows/checks.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-name: repository checks
-
-on:
- pull_request:
- push:
- branches: [main, "codex/**"]
-
-permissions:
- contents: read
-
-jobs:
- check:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v5
- - uses: actions/setup-node@v5
- with:
- node-version: 22
- cache: npm
- cache-dependency-path: |
- contracts/package-lock.json
- web/package-lock.json
- - name: Check repository navigation
- run: node tool/check-repository.mjs
- - name: Check contracts
- working-directory: contracts
- run: |
- npm ci
- npm test
- - name: Check web types
- working-directory: web
- run: |
- npm ci
- npm run check
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6445938
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,78 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ flutter:
+ name: Flutter app
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: app
+ steps:
+ - uses: actions/checkout@v5
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ flutter-version: 3.47.7
+ cache: true
+ - run: flutter pub get
+ - name: Analyze
+ run: flutter analyze
+ - name: Test
+ run: flutter test
+ - name: Fingerprint harness (must print FAILURES 0)
+ run: dart run tool/canary_fingerprint_harness.dart | tail -4
+
+ receipts-dart:
+ name: Receipts library (Dart)
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: packages/receipts-dart
+ steps:
+ - uses: actions/checkout@v5
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ flutter-version: 3.47.7
+ cache: true
+ - run: dart pub get
+ - run: dart analyze --fatal-infos
+ - run: dart format --output=none --set-exit-if-changed .
+ - run: dart test
+
+ deno:
+ name: Relayer + receipts library (TypeScript)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: denoland/setup-deno@v2
+ with:
+ deno-version: v2.9.x
+ - name: Receipts library checks
+ working-directory: packages/receipts-ts
+ run: |
+ deno check mod.ts cli.ts test/
+ deno lint
+ deno fmt --check
+ deno test
+ - name: Relayer checks
+ working-directory: app/supabase/functions/canary
+ run: |
+ deno check index.ts
+ deno test --allow-env
+ - name: Relayer bundles for deploy
+ run: deno bundle --platform=deno app/supabase/functions/canary/index.ts -o "$RUNNER_TEMP/canary.bundle.js"
+ - name: Devnet spike type-checks
+ run: deno check tool/solana-spike/send_memo.ts
diff --git a/AGENTS.md b/AGENTS.md
index 63ca829..d732cc9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,59 +1,45 @@
-# NO SUS — Monad Experiment agent map
-
-This repository is an isolated, **testnet-only** experiment. Read
-[docs/STATUS.md](docs/STATUS.md), then only the local guide for files you will change.
-
-## Current workstream: NO SUS Canary (owner decision, 19 Sep 2026)
-
-- Brand: **NO SUS**. Feature: **NO SUS Canary**. This supersedes the "NO SUS — Monad Experiment"
- naming rule below for user-facing text.
-- Build plan: [docs/canary/NOSUS_CANARY_BUILD_PLAN.md](docs/canary/NOSUS_CANARY_BUILD_PLAN.md)
- (executed; the live code in `app/` and `contracts/` is the source of truth). Verification log:
- [docs/canary/VERIFICATION.md](docs/canary/VERIFICATION.md).
-- Canary is server-gated copy delivery and is described as such. It does not use Lit, does not
- change Gate T1, and does not change `monadThresholdVerified`.
-- Still requires explicit owner approval each time: contract deploys, funding wallets, applying
- SQL, setting secrets, deploying functions, creating public repos or Pages, commits and pushes.
-
-## Product boundaries
-
-- Brand it **NO SUS — Monad Experiment**. Do not call it Rasid and do not change the production
- NO SUS repository, credentials, Supabase project, deployment, or URL.
-- Use only harmless, personally owned test notes. Never use credentials, third-party documents,
- employment records, financial information, or sensitive personal data.
-- The product may say "threshold-gated decryption after a wallet acknowledgement." It must never
- claim legal proof, a human read, screenshot prevention, serverless deletion, or identity proof.
-- The contract must never receive plaintext, encryption keys, salts, filenames, or document content.
-- Threshold custody is a release gate, not decoration: if the Monad-to-Lit proof is not clean, do
- not present it as shipped and never substitute a hidden server key-release path.
-
-## How to navigate
-
-| Area | Read first |
-|---|---|
-| Contract | `contracts/README.md` and `contracts/AGENTS.md` |
-| Web app | `web/README.md` and `web/AGENTS.md` |
-| Storage schema | `supabase/README.md` |
-| Handover | `docs/CURSOR_HANDOFF.md` |
-| Lit technical gate | `docs/LIT_MONAD_SPIKE.md` |
-| Parallel work | `docs/AGENT_COLLABORATION.md` |
-
-## Working rules
-
-- Use the smallest necessary change. Do not add an abstraction, dependency, code generator, or
- generic utility without a current consumer.
-- Keep all secrets in ignored environment files. `NEXT_PUBLIC_*` values are public by design; never
- put service-role keys, private keys, Lit account keys, or test-wallet keys there.
-- Work in a dedicated branch and small commits. Update `docs/STATUS.md` and local README files when
- behavior, contracts, or setup changes.
-- Normal work uses focused checks and localhost. Do not deploy, fund wallets, apply a Supabase
- migration, or use a real test note without explicit user direction.
-- After each substantive response that changes files, ask exactly: "Would you like to finalize today's
- work? If yes, say whether to verify only, build a target, or deploy a named target."
-
-Would you like to finalize today's work? If yes, say whether to verify only, build a target, or deploy a named target.
-
-## Finalization
-
-"Verify only" permits the contract tests, web checks, repository checks, and the real Monad/Lit
-technical gate. A deployment additionally needs an explicitly named target.
+# NO SUS Canary: agent map
+
+Read [docs/STATUS.md](docs/STATUS.md) first, then only the guide for the files you will change.
+
+## What this is
+
+NO SUS Canary: every reader of a note gets a uniquely marked copy, and every seal and every open is
+one SPL Memo transaction on **Solana devnet**, sent by a relayer (Supabase Edge Function `canary`)
+so readers need no wallet. Leak matching runs on the sender's device.
+
+## Map
+
+| Area | Code | Read first |
+|---|---|---|
+| Canary UI and fingerprinting | `app/lib/features/canary/` | `app/AGENTS.md` |
+| Relayer | `app/supabase/functions/canary/` | `docs/ARCHITECTURE.md` |
+| Receipt format and libraries | `packages/receipts-ts/`, `packages/receipts-dart/`, `packages/test-vectors/` | `docs/RECEIPTS_SPEC.md` |
+| Schema | `app/supabase/migrations/2026091909*`, `2026101009*` | `docs/SELF_HOSTING.md` |
+| Security model | — | `SECURITY.md` |
+
+## Rules
+
+- Backend: experiment project `ffidvfguojalzpclipzi` only. Never touch the NO SUS production
+ project, its credentials or deployment.
+- Never put note text, keys, names or device ids on chain. Memos carry hashes only; the format is
+ frozen at v1 (see the spec) and both libraries must stay byte-identical with the test vectors.
+- Secrets live in Supabase function secrets or git-ignored files (`.env.example` lists them). The
+ Supabase publishable key in `app/lib/config/supabase_credentials.dart` is public by design.
+- Keep the legacy EVM prototype path off: `CANARY_MONAD` stays false in Flutter and
+ `CANARY_CONTRACT_ADDRESS` stays unset for the function. User-facing text mentions Solana only.
+- Never claim screenshot prevention, legal proof, identity proof or proof that a human read a note.
+- Use harmless test notes only.
+- Smallest change that works. No new dependency or abstraction without a current consumer.
+- Owner approval is needed every time for: commits and pushes, merges, deploying the function,
+ applying SQL, setting secrets, funding keys or spending devnet SOL, changing gh-pages, and
+ making anything public.
+
+## Checks (CI runs all of these)
+
+```bash
+(cd app && flutter analyze && flutter test && dart run tool/canary_fingerprint_harness.dart)
+(cd app/supabase/functions/canary && deno check index.ts && deno test --allow-env)
+(cd packages/receipts-ts && deno check mod.ts cli.ts && deno lint && deno fmt --check && deno test)
+(cd packages/receipts-dart && dart pub get && dart analyze && dart format --set-exit-if-changed . && dart test)
+```
diff --git a/CLAUDE.md b/CLAUDE.md
index edc3f27..3085c49 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,3 +1,3 @@
-# NO SUS — Monad Experiment
+# NO SUS Canary
Read [AGENTS.md](AGENTS.md). It is the single source of truth for humans and coding agents.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..11069ed
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..1f01b77
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,8 @@
+NO SUS Canary
+Copyright 2026 Shubham Sahu (NO SUS)
+
+Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+Third-party packages listed in app/pubspec.lock, packages/receipts-dart/pubspec.lock and
+packages/receipts-ts/deno.lock keep their own licenses. The fonts in app/assets/google_fonts/
+are from Google Fonts and keep their own (SIL Open Font) licenses.
diff --git a/README.md b/README.md
index 2231a94..e981e96 100644
--- a/README.md
+++ b/README.md
@@ -1,121 +1,219 @@
-
-
-
+
-
-
-
+
+
+
+
-
+**NO SUS Canary lets you share a sensitive note with a group so that every reader gets a secretly
+different copy, and every seal and every open is recorded as a Solana transaction. If a screenshot
+leaks, NO SUS tells you whose copy it was.**
-## Step 1: Watch
+[**Live demo**](https://shubham-sahu.me/nosus-canary/) ·
+[Pitch video (2:24)](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-pitch.mp4) ·
+[Demo video (1:18)](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-demo.mp4) ·
+[Judge FAQ](SUBMISSION.md) ·
+[A real receipt on Solana Explorer](https://explorer.solana.com/tx/5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2?cluster=devnet) ·
+[Security model](SECURITY.md)
-
-## Step 2: Try it
+## The problem
-**60 seconds. No wallet. No install.**
+Salary sheets, offer letters, investor decks, exam papers and internal memos leak as screenshots
+and forwards. When they do, nobody can show whose copy it was. Name watermarks get cropped.
+Document-security tools ask every reader to make an account or install an app, so people go back
+to WhatsApp and email.
-1. **Make a note.** Open the [live app](http://shubham-sahu.me/nosus-canary/) → **PLAY CANARY** → **Create a Canary link** → **Use the demo note** → **Create Canary link**.
-2. **Be a reader.** Open that link in a private window, type any name, tap **Open my copy**.
-3. **Catch the leak.** Copy the reader's text → **Check a leak** → paste. It names the reader.
+## The insight
-
+Give every reader a slightly different copy (a "canary trap"), and make the record of who got
+which copy something neither side can edit.
-## Step 3: Why Monad
+- **Small wording changes survive.** `do not` / `don't`, `5` / `five`, `until` / `till`, plus an
+ invisible marker. They survive copy-paste, retyping into a chat and OCR, so even part of a
+ leaked text points to one copy.
+- **Receipts are now cheap and fast.** A Solana memo costs about 0.000005 SOL and confirms in about
+ a second. That makes one neutral, timestamped receipt per reader affordable. A row in our own
+ database is only our word.
-- **One transaction per reader.** Every **Open my copy** is recorded on-chain.
-- **Final in under a second.** About **0.015 MON** each.
-- **Parallel execution.** A whole room can open the link at once.
+## The product
-## Judge checklist
+| | What happens | Where it runs |
+|---|---|---|
+| **1. Write once** | You write the note. Canary makes up to 100 copies that read the same but are each unique, and encrypts them. A **seal** memo goes to Solana with one hash of all copies. | Your browser (AES-256, key only in the link's `#fragment`) + relayer |
+| **2. Share one link** | Each reader types a name and gets the next unused copy. An **open** memo goes to Solana with an anonymous tag. **Readers need no wallet, no app and no sign-up.** | Reader's browser + relayer |
+| **3. Catch a leak** | Paste the leaked text (or part of it). Canary names the copy, or names nobody if too little survived. | Your device only |
+
+
+
+
+
+
+
+
+
Each reader gets their own copy
+
A pasted leak names the copy
+
Every open is a memo on Solana
+
+
+
+## Traction
+
+We are early: live, pre-revenue, devnet only.
+
+- **Canary on Solana devnet:** live at [shubham-sahu.me/nosus-canary](https://shubham-sahu.me/nosus-canary/)
+ since 10 Oct 2026. The relayer has written **31 receipts** so far (20 Oct 2026), all from our own
+ tests and demos. Every one is public on the
+ [relayer's account page](https://explorer.solana.com/address/CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx?cluster=devnet).
+- **Parent product, [NO SUS](https://nosus.foo):** a privacy app for notes and files that open once
+ and are watermarked with the viewer's name. In production since July 2026 (Google Play closed
+ test, APK and web), 10+ releases. On 10 Oct 2026: 18 registered users, 6 active in the last 30
+ days.
+
+## Why Solana
+
+1. **Cost per open.** A receipt is one SPL Memo instruction with one signature, so it pays the
+ 5,000-lamport base fee (0.000005 SOL). One receipt per reader stays affordable even for a
+ 100-reader note.
+2. **Speed.** Receipts confirm in about a second, so the sender sees "opened" while the reader is
+ still reading. In the recording above, the sealed link was ready about 3 s after clicking
+ Create, including encryption and confirmation.
+3. **Neutral and append-only.** Once written, neither the sender, the reader nor NO SUS can edit
+ a receipt. Anyone can check it on Solana Explorer without trusting our database.
+4. **No program to audit, no wallet to install.** We use the standard SPL Memo program, which
+ Explorer shows as plain text. The relayer pays fees, so readers never see a wallet.
+5. **Room to scale.** Batching many opens into one transaction (Merkle roots, state compression)
+ and the Solana dApp Store for the Android app are both on the roadmap below.
+
+## Architecture
-| | What | Where |
+```mermaid
+flowchart LR
+ subgraph S["Sender's device"]
+ W["Write note"] --> K["Make N marked copies word swaps + invisible marker"]
+ K --> E["Encrypt each copy AES-256, key stays in the link #fragment"]
+ L["Leaked text"] --> M["Match on device names a copy or nobody"]
+ end
+ E -- "ciphertexts + one hash of all copies" --> F["Relayer Supabase Edge Function 'canary'"]
+ RD["Reader's browser no wallet"] -- "open + typed name" --> F
+ F -- "their ciphertext only" --> RD
+ F -- "seal memo / open memo hashed ids only" --> SOL[("Solana devnet SPL Memo program")]
+ SOL -- "signature" --> X["Solana Explorer link on every proof chip"]
+ F <--> DB[("Postgres ciphertexts, names, signatures")]
+```
+
+| Where | What it holds | What it never holds |
|---|---|---|
-| **Done** | Live app | [shubham-sahu.me/nosus-canary/](http://shubham-sahu.me/nosus-canary/) |
-| **Done** | Contract on Monad testnet | [`0xb1a1858866122c84cf97861ca815fb070e010e68`](https://testnet.monadscan.com/address/0xb1a1858866122c84cf97861ca815fb070e010e68) |
-| **Done** | Verified source code | [View verified source](https://sourcify.dev/server/repo-ui/10143/0xb1a1858866122c84cf97861ca815fb070e010e68) |
-| **Done** | Live transaction in the demo | Every **Open my copy** tap |
-| **Done** | Public repo you can run | [Run it yourself](#more) |
-| Soon | Build in public | _soon_ · _soon_ · _soon_ |
-| Soon | Bonus: Monad mainnet | _soon_ |
-| **Done** | Bonus: custom domain | [shubham-sahu.me](http://shubham-sahu.me/nosus-canary/) |
+| Solana (public) | Hashed note id, copy count, one hash of all copies, expiry, copy number, salted reader tag | Note text, keys, names, device ids |
+| Server (Supabase) | Encrypted copies, reader names and open times (shown to the sender), signatures | Note text, the decryption key |
+| Sender's device | The note, every copy's fingerprint, the owner secret | — |
+| The link | Note id and the AES key, in the `#fragment` (browsers never send it to a server) | — |
-> **Honest limits:** it names *whose copy* leaked, not *who shared it*. It doesn't stop screenshots. It makes them traceable.
+More: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and the receipt format in
+[docs/RECEIPTS_SPEC.md](docs/RECEIPTS_SPEC.md).
-## More
+## Receipts you can check now
-
-How the fingerprint works
+| Receipt | Signature | Memo |
+|---|---|---|
+| Seal, 10 copies (20 Oct 2026) | [`QqdiqT84…uL11wb`](https://explorer.solana.com/tx/QqdiqT84UfFyHNhpfLrK32HfqbUB5vF8qtC4DiAQ7wZZGn2MkUVJGGpVPpgmk4MCxVLtkhmZm6rbuBxqyuL11wb?cluster=devnet) | `{"p":"nosus-canary","v":1,"t":"seal","note":"0x6b0b…3698","copies":10,"hash":"0x…","exp":…}` |
+| Open, copy 1 (Priya) | [`5TXFPfTD…oW3wAsy2`](https://explorer.solana.com/tx/5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2?cluster=devnet) | `{"p":"nosus-canary","v":1,"t":"open","note":"0x6b0b…3698","copy":0,"tag":"0x4096…c2b0"}` |
+| Open, copy 2 (Ben) | [`3tqhYUQ2…h7GMTcT1`](https://explorer.solana.com/tx/3tqhYUQ2BppvWh6dy8q5u7U23NpZ7FqfAnd34B58GTriGVZXtykcPj1i4sF7bNh4t8ShnhQokySNz7Uwh7GMTcT1?cluster=devnet) | same note, `"copy":1` |
+| First seal ever (10 Oct 2026) | [`2BHqnuCx…FRk512bQ`](https://explorer.solana.com/tx/2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ?cluster=devnet) | `"t":"seal"`, 3 copies |
+| All receipts | [relayer `CCD4SUQt…hueQJx`](https://explorer.solana.com/address/CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx?cluster=devnet) | |
-- **Write once.** NO SUS swaps safe word twins (`don't` / `do not`, `five` / `5`) to make up to **100 copies**. Each looks normal, each is unique.
-- **Seal it.** Copies are encrypted in your browser. The key lives only in the link's `#fragment`. `sealNote` puts one hash of all copies on Monad before anyone reads.
-- **Share it.** Each reader gets the next unused copy. `openCopy` records it on Monad with an anonymous tag, never the name.
-- **Catch it.** Paste a leak into **Check a leak**. Matching runs on your device. If too little text survived, it names nobody.
-
+Decode any receipt yourself, without our app or server:
-
-Run it yourself
+```bash
+deno run --allow-net packages/receipts-ts/cli.ts 5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2
+```
-You need Flutter 3.44+ and Node 22+.
+## Run it locally in under 5 minutes
+
+You need [Flutter](https://docs.flutter.dev/get-started/install) 3.47+ and Chrome. The app talks
+to the hosted devnet backend with its public (publishable) key, so there is nothing else to set up.
```bash
git clone https://github.com/https-shubhamsahu/nosus-canary.git
-cd nosus-canary
-
-# App (web)
-cd app
+cd nosus-canary/app
flutter pub get
-flutter run -d chrome
-
-# Contract
-cd ../contracts
-npm ci
-npx hardhat test
+flutter run -d chrome --dart-define=NOSUS_CANARY_ONLY=true
```
-Own backend and contract: see [`docs/canary/NOSUS_CANARY_BUILD_PLAN.md`](docs/canary/NOSUS_CANARY_BUILD_PLAN.md).
-
+Run the checks that CI runs (add [Deno](https://deno.com) 2.x for the last three):
-
-Architecture
-
-```mermaid
-%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FAF6EC','primaryTextColor':'#141414','primaryBorderColor':'#141414','lineColor':'#F28C28','fontFamily':'monospace'}}}%%
-flowchart LR
- S["Sender app"] -- "encrypted copies" --> F["canary function"]
- F -- "sealNote" --> M[("NoSusCanary on Monad")]
- R["Reader"] -- "open + name" --> F
- F -- "openCopy" --> M
- F -- "their copy" --> R
+```bash
+(cd app && flutter analyze && flutter test)
+(cd packages/receipts-dart && dart pub get && dart analyze && dart test)
+(cd packages/receipts-ts && deno test)
+(cd app/supabase/functions/canary && deno check index.ts && deno test --allow-env)
```
-- **Chain:** hashes and anonymous tags only.
-- **Server:** encrypted copies it can't read.
-- **Sender's device:** the key and every copy's fingerprint.
-
-
-
-Test results
+Your own backend and relayer: [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md). Secrets template:
+[.env.example](.env.example).
+
+## Security notes
+
+- **On chain:** only sha256-based ids and hashes. The reader tag is salted with a server secret
+ and bound to the note and copy, so tags can't be linked across notes. Never note text, keys,
+ names or device ids.
+- **On the server:** encrypted copies it cannot read (the key never leaves the link fragment),
+ plus each reader's typed name and open time, which the sender sees.
+- **On the sender's device:** the original note and the fingerprint of every copy. Leak matching
+ runs here. Clearing browser data deletes it.
+- **What a receipt proves:** that the NO SUS relayer recorded this seal or this open at this time,
+ and that the note's copies were committed before anyone read them. It does **not** prove who a
+ person is, that a human read the text, or who shared a leak. Canary names *whose copy* leaked.
+- **Known limits:** it does not stop screenshots; a reader who rewrites the text in their own
+ words can defeat it; two readers who blend their copies can confuse the match (measured rates
+ in the threat model). Encryption is AES-256 in CTR mode without an authentication tag today;
+ moving to AES-GCM is on the roadmap.
+- **Secrets:** none in the repo. The whole history (46 commits, all branches) was scanned on
+ 20 Oct 2026; the only key found is the Supabase publishable key, which is public by design.
+
+Full threat model and how to report a problem: [SECURITY.md](SECURITY.md).
+
+## Roadmap
+
+| Next | What | Status |
+|---|---|---|
+| **Mainnet** | Receipts on Solana mainnet-beta with priority fees, retries with backoff, and relayer key rotation | Devnet live |
+| **Open-source receipts library** | `@nosus/receipts` (TypeScript) and `nosus_receipts` (Dart): encode, parse, send, fetch and verify Canary receipts, with shared test vectors | **v0.1 in [`packages/`](packages/)**; the live relayer runs it; not yet published to JSR, npm or pub.dev |
+| **Solana dApp Store / Seeker** | Canary inside the NO SUS Android app, submitted to the Solana dApp Store | Android build exists; not submitted |
+| **PDFs and images** | Per-copy marks for files, not only text | Not started |
+| **Merkle / compressed batching** | Many opens in one transaction (a Merkle root per batch, or state compression) to cut cost at scale | Not started |
+| **Authenticated encryption** | AES-GCM per copy, so a tampered copy fails to open | Not started |
-Fingerprint test harness, 15-slot demo note:
+## What's in this repo
-| Leak type | Right copy | Wrong copy |
-|---|---|---|
-| Full copy, marker removed | 300 / 300 | 0 |
-| OCR-style (caps, no punctuation) | 300 / 300 | 0 |
-| 2% random typos | 300 / 300 | 0 |
-| Partial excerpts (30–70%) | named only when sure | 0 / 600 |
-
+| Path | What |
+|---|---|
+| [`app/`](app/) | The NO SUS Flutter app (web + Android). Canary lives in [`app/lib/features/canary/`](app/lib/features/canary/); the deployed site is the standalone build (`NOSUS_CANARY_ONLY=true`). |
+| [`app/supabase/functions/canary/`](app/supabase/functions/canary/) | The relayer: one Supabase Edge Function (Deno) that stores ciphertexts and writes the Solana memos |
+| [`app/supabase/migrations/`](app/supabase/migrations/) | Database schema, including `canary_notes` and `canary_copies` |
+| [`packages/receipts-ts/`](packages/receipts-ts/) · [`packages/receipts-dart/`](packages/receipts-dart/) | The receipts library, v0.1, plus [shared test vectors](packages/test-vectors/) from real devnet transactions |
+| [`docs/`](docs/) | Architecture, receipt spec, self-hosting, status and verification log |
+| [`tool/solana-spike/`](tool/solana-spike/) | Script that sends a test seal and open memo to devnet with your own key |
-Full technical write-up (setup, deployment, contract and relayers): [`docs/TECHNICAL_README.md`](docs/TECHNICAL_README.md)
+## Team
----
+**Shubham Sahu**, founder of NO SUS. B.E. Electronics & Computer Engineering, TSEC Mumbai (2028).
+[nosus.foo](https://nosus.foo) · [shubhamsahu@nosus.foo](mailto:shubhamsahu@nosus.foo) ·
+[LinkedIn](https://www.linkedin.com/in/shubhamsahu9372) · [X @isagi0011](https://x.com/isagi0011)
-**Team:** Shubham Sahu · Built at **Monad Blitz Mumbai V4** · part of **NO SUS**
+## License
-
+[Apache-2.0](LICENSE). Use harmless test notes only on the devnet demo.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..d93cef1
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,93 @@
+# Security
+
+## Reporting a problem
+
+Email **shubhamsahu@nosus.foo** with "security" in the subject. Please don't open a public issue
+for a vulnerability. The demo runs on Solana devnet and a test
+backend; never put real secrets or personal data into it.
+
+## What goes where
+
+| Data | Sender's device | Link | Server (Supabase) | Solana (public) |
+|---|---|---|---|---|
+| Note text and every copy | ✅ | — | encrypted only | — |
+| AES-256 key | ✅ | in the `#fragment` | — | — |
+| Fingerprint of each copy (which words were swapped) | ✅ | — | — | — |
+| Owner secret (unlocks the dashboard) | ✅ | — | stored as sha256 only | — |
+| Reader's typed name, open time | shown on dashboard | — | ✅ | — |
+| Reader's device id (random, per browser) | — | — | stored as sha256 only | — |
+| Note id | ✅ | ✅ | ✅ | sha256 only |
+| Commitment to all copies | ✅ | — | ✅ | ✅ |
+| Which copy was opened | ✅ | — | ✅ | ✅ |
+| Reader tag | — | — | ✅ | ✅ |
+
+The browser never sends the `#fragment` to a server, so the key reaches only people who have the
+link. The Canary tables have row-level security on and no grants to the public (`anon`) role; only
+the edge function, using the service role, can read or write them.
+
+## How each value is computed
+
+| Value | Formula | Computed by |
+|---|---|---|
+| Chain note id | `0x` + sha256(`"nosus-canary:" + noteId`) | device and relayer |
+| Copy digest | sha256(`salt + ":" + index + ":" + copyText`) with a random 32-byte salt kept on the device | device |
+| Copies hash (sealed on chain) | `0x` + sha256(all copy digests joined in copy order) | device |
+| Owner hash | sha256(`ownerSecret`), the secret is 32 random bytes kept on the device | device |
+| Device hash | sha256(`"device:" + deviceId`) | relayer |
+| Reader tag (on chain) | `0x` + sha256(`CANARY_TAG_SALT + ":" + noteId + ":" + copyIndex + ":" + deviceHash`) | relayer |
+
+`CANARY_TAG_SALT` is a 32-byte server secret. Without it nobody can link a tag to a device or link
+two tags from different notes. The memo format is specified in
+[docs/RECEIPTS_SPEC.md](docs/RECEIPTS_SPEC.md).
+
+## Threat model
+
+**What Canary is for:** naming which reader's copy a leaked text came from, and keeping a neutral,
+timestamped record of the seal and of each open.
+
+| Who | What they try | What happens |
+|---|---|---|
+| A reader | Screenshot or copy-paste their copy | The copy is identified if enough text survives. See the measured rates below. |
+| A reader | Strip invisible characters, change case or punctuation, OCR a screenshot | Still identified: the word swaps carry the fingerprint. |
+| A reader | Retype the note in their own words | Can defeat it. Canary marks wording, not meaning. |
+| Two readers | Blend their two copies | Usually names one of them; sometimes names an innocent reader (rates below). The app warns about this. |
+| Anyone with the link | Open a copy and type a fake name | Works. A typed name is not an identity. The link is the access control: share it like a password. |
+| Someone with the sender's device | Read the note, open the dashboard | Works. Device security is out of scope. |
+| A passive observer of Solana | Learn the note, names or devices | Sees only hashes, copy counts, copy numbers, expiry and timestamps. |
+| The server operator (us) | Read notes | Cannot: the server never has the key. |
+| The server operator (us) | Give two readers the same copy, swap ciphertexts, or skip a receipt | Possible today. Partly detectable: the seal commits to every copy before anyone reads, and the sender's device can check any copy a reader shows them against it. Authenticated encryption (AES-GCM) is on the roadmap so a tampered copy fails to open. |
+| Whoever steals the relayer key | Write fake memos from our relayer address | Possible. The key is a devnet key stored as a Supabase secret, never in the repo. Mainnet adds key rotation. |
+
+**Measured leak matching** (`app/tool/canary_fingerprint_harness.dart`, run in CI; a note with 15
+swappable words, like the app's demo note):
+
+| Leak | 20 copies | 50 copies | 100 copies |
+|---|---|---|---|
+| Full copy without invisible marker, OCR-style text, 2% typos | 300 / 300 each, 0 wrong | 300 / 300 each, 0 wrong | 300 / 300 each, 0 wrong |
+| Partial excerpts: named / wrong | 431 / 600, 0 wrong | 313 / 600, 0 wrong | 188 / 600, 0 wrong |
+| Two copies blended: an innocent named | 1 / 600 | 8 / 600 | 23 / 600 |
+
+## What we do not claim
+
+- Canary does not stop screenshots. It makes them traceable.
+- A receipt proves that the NO SUS relayer recorded an event at a time. It does not prove who a
+ person is, that a human read the text, or who shared a leak.
+- It is not legal evidence by itself.
+- Deleting data from the server is an operational step, not something anyone can verify.
+
+## Known gaps
+
+- **Encryption mode.** Copies use AES-256 from `package:encrypt` with its default mode (SIC, which
+ is CTR) and a random 16-byte IV per copy. There is no authentication tag. Planned: AES-GCM.
+- **Relayer trust.** One relayer key signs every memo. Planned: several keys with rotation, and
+ receipts batched under a Merkle root so a reader can check their own open.
+- **Devnet only.** Devnet can be reset; receipts there are for demos, not records.
+
+## Secrets in this repository
+
+None. On 20 Oct 2026 every commit on every branch (46 commits, 17 refs, plus unreachable objects)
+was scanned for Solana and EVM private keys, Supabase service-role and secret keys, GitHub, Google,
+AWS, Stripe and AI-provider tokens, PEM keys and signing files. The only key-like value is the
+Supabase **publishable** key in `app/lib/config/supabase_credentials.dart`, which is public by
+design and gated by row-level security. Real secrets live only in Supabase function secrets and in
+git-ignored files; see [.env.example](.env.example).
diff --git a/SUBMISSION.md b/SUBMISSION.md
new file mode 100644
index 0000000..722bd42
--- /dev/null
+++ b/SUBMISSION.md
@@ -0,0 +1,112 @@
+# NO SUS Canary: for judges and reviewers
+
+**One line:** share a sensitive note with a group; every reader gets a secretly different copy;
+every seal and every open is a Solana transaction; if a screenshot leaks, NO SUS names whose copy
+it was.
+
+| | Link |
+|---|---|
+| Live app (Solana devnet) | https://shubham-sahu.me/nosus-canary/ |
+| Code (Apache-2.0) | https://github.com/https-shubhamsahu/nosus-canary |
+| Pitch video (2:24) | [nosus-canary-pitch.mp4](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-pitch.mp4) |
+| Product demo, narrated (1:18) | [nosus-canary-demo.mp4](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-demo.mp4) |
+| 36-second silent demo | [GIF](docs/media/canary-demo.gif) · [MP4](docs/media/canary-demo.mp4) |
+| A real open receipt | [`5TXFPfTD…oW3wAsy2` on Solana Explorer](https://explorer.solana.com/tx/5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2?cluster=devnet) |
+| Every receipt so far | [Relayer account on Solana Explorer](https://explorer.solana.com/address/CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx?cluster=devnet) |
+| Receipt format | [docs/RECEIPTS_SPEC.md](docs/RECEIPTS_SPEC.md) |
+| Threat model | [SECURITY.md](SECURITY.md) |
+| Contact | Shubham Sahu · [shubhamsahu@nosus.foo](mailto:shubhamsahu@nosus.foo) |
+
+## Check it yourself in 60 seconds
+
+1. Open the [live app](https://shubham-sahu.me/nosus-canary/) → **Create a Canary link** →
+ **Use the demo note** → **Create Canary link**. A "Sealed on Solana" chip appears.
+2. Open the link in a private window, type any name, tap **Open my copy**. Your copy shows a
+ "Recorded on Solana" chip. Click it: Solana Explorer shows one Memo instruction with hashes only.
+3. Copy the reader's text. Back in the first window, open **Who opened it** and paste it into
+ **Check a leak** → **Find the copy**. It names the reader.
+
+Use harmless text only. This is devnet.
+
+## 2-minute demo script
+
+| Time | Do | Say |
+|---|---|---|
+| 0:00 | Show the landing page | "Salary sheets, offer letters and decks leak as screenshots. Today nobody can show whose copy it was." |
+| 0:15 | **Create a Canary link** → **Use the demo note** | "I write the note once. Canary makes up to 100 copies that read the same, with tiny word swaps like *don't* and *do not*, and an invisible marker." |
+| 0:30 | Point at the fingerprint meter, pick 10 readers, **Create Canary link** | "Every copy is encrypted in my browser. The key stays in the link. Before anyone reads, a seal goes to Solana: one hash of all ten copies." |
+| 0:45 | Click **Sealed on Solana** → Explorer | "That's a real devnet transaction. One SPL Memo. No text, no names, just hashes." |
+| 1:00 | Open the link in a private window, type "Priya", **Open my copy** | "Priya needs no wallet and no app. She gets her own copy, and her open is another Solana memo, paid by our relayer for about 0.000005 SOL." |
+| 1:20 | Copy Priya's text. Sender window: **Who opened it**, paste into **Check a leak**, **Find the copy** | "Now imagine Priya's copy shows up in a group chat. I paste it in. Matching runs on my device." |
+| 1:35 | Result card: **The canary sang · Priya · Copy 1 / 10** | "It names her copy. If too little text survived, it names nobody. It never guesses." |
+| 1:45 | Click **Proof on Solana** | "And the receipt shows when her copy was opened, on a chain neither of us can edit." |
+| 1:55 | Back to README roadmap | "Next: mainnet, the open receipts library, PDFs, and the Solana dApp Store." |
+
+## FAQ
+
+**What does a receipt prove, exactly?**
+That the NO SUS relayer recorded a seal (with a commitment to every copy) or an open of copy N, at
+a chain timestamp, and that the copies were committed before anyone read them. It does not prove
+a person's identity, that a human read the text, or who shared a leak. Canary names *whose copy*
+leaked. Phones get lost and screens get shown to friends.
+
+**Why put this on a chain instead of in your database?**
+A database row is our word, and we can edit it. A Solana memo is public, timestamped and
+append-only, so the sender, the reader and a third party can all check the same record without
+trusting NO SUS.
+
+**Why Solana?**
+One receipt per reader has to be cheap (each receipt pays the 5,000-lamport base fee) and fast (about a
+second to confirm, so the sender sees "opened" right away). The standard SPL Memo program means
+there is no custom program to audit, and Explorer shows the memo as plain text.
+
+**Does the server see the note?**
+No. Copies are encrypted on the sender's device and the AES key lives in the link's `#fragment`,
+which browsers never send to a server. The server does see each reader's typed name and when they
+opened, because the sender's dashboard shows it. Names never go on chain.
+
+**Can a reader strip the marks?**
+Removing the invisible marker is not enough: the word swaps still identify the copy. From the
+fingerprint test harness (`app/tool/canary_fingerprint_harness.dart`, run in CI), on a note with
+15 swappable words like the app's demo note:
+
+| Leak | 20 copies | 50 copies | 100 copies |
+|---|---|---|---|
+| Full copy, invisible marker removed | 300 / 300 right | 300 / 300 | 300 / 300 |
+| OCR-style (caps, no punctuation) | 300 / 300 right | 300 / 300 | 300 / 300 |
+| 2% random typos | 300 / 300 right, 0 wrong | 300 / 300, 0 wrong | 300 / 300, 0 wrong |
+| Partial excerpts (named / wrong) | 431 / 600, 0 wrong | 313 / 600, 0 wrong | 188 / 600, 0 wrong |
+| Two readers blend their copies: an innocent named | 1 / 600 | 8 / 600 | 23 / 600 |
+
+When an excerpt is too short, Canary names nobody instead of guessing. A reader who rewrites the
+whole note in their own words can defeat it. Blending is the known weak spot; the app says so.
+
+**Who pays the fees? Do readers need a wallet?**
+The relayer (a Supabase Edge Function) pays and signs every memo. Readers never see a wallet.
+
+**Are reader tags private?**
+Each tag is `sha256(server salt : note id : copy number : sha256(device id))`. Without the server
+salt nobody can link tags across notes or back to a device.
+
+**Is it on mainnet?**
+Not yet. Devnet only. Mainnet with priority fees and retries is the first roadmap item.
+
+**What is open source?**
+Everything in this repo, under Apache-2.0: the Flutter app, the relayer, the database schema, and
+the receipts library v0.1 in TypeScript and Dart (`packages/`), which any app can use to write and
+verify the same receipts.
+
+**What is the business?**
+Pre-revenue. First users we are building for: small teams, HR, founders sharing decks, and college
+groups in India. Planned paid features for teams: leak alerts, a team dashboard and audit exports.
+None of those are built yet.
+
+**What traction is there?**
+Canary has been live on devnet since 10 Oct 2026; its relayer has written 31 receipts (20 Oct
+2026), all from our own tests and demos. The parent app, NO SUS, has been in production since July
+2026 with 18 registered users and 6 active in the last 30 days (10 Oct 2026). We do not claim more
+than that.
+
+**Who is building it?**
+Shubham Sahu, founder of NO SUS, B.E. Electronics & Computer Engineering at TSEC Mumbai (2028).
+NO SUS has shipped 10+ releases since July 2026.
diff --git a/app/AGENTS.md b/app/AGENTS.md
index 7f41eb2..44bbea4 100644
--- a/app/AGENTS.md
+++ b/app/AGENTS.md
@@ -1,20 +1,18 @@
-# NO SUS — Monad Experiment: Flutter app
+# NO SUS Canary: Flutter app
-Full NO SUS app copied from `no_sus` at
-`ec29fdae94a68fefcb5efea2797224dc2245549c`, explicitly requested by the owner.
-The root AGENTS.md still applies. Production files are not edited.
+This is the NO SUS Flutter app (copied from the production `no_sus` repo at
+`ec29fdae94a68fefcb5efea2797224dc2245549c`). NO SUS Canary lives in `lib/features/canary/`. The
+root `AGENTS.md` still applies.
-- Preserve the five tabs and existing app features and deep-link formats.
-- Add Monad under `lib/features/monad/`, reached from Welcome and Workspace.
- Use the existing Flutter theme, accessibility and navigation.
-- Backend: **ffidvfguojalzpclipzi** only. Never apply this fork's SQL to
- production `rxfnazmusofikwaggntb`. No production users or storage are copied.
-- Target: **monad.nosus.foo**, web only. Native source is retained for parity;
- do not release it with the copied production application/signing identity.
-- No secrets were copied. The publishable Supabase key is a public client key.
-- Gate T1 still blocks Monad encrypted sharing and public release. Reading
- `canDecrypt` alone is not proof of successful Lit decryption.
-- Run `flutter analyze`, `flutter test`, and `flutter build web --base-href /`.
- SDK: `C:/Users/shubh/AppData/Local/flutter/bin/flutter.bat`.
-- Do not format unrelated copied Dart files. Migrations remain immutable;
- experiment-specific changes are append-only.
+- The deployed site is the standalone Canary build: `--dart-define=NOSUS_CANARY_ONLY=true`, base
+ href `/nosus-canary/`. Without that flag you get the full NO SUS app, with Canary reachable from
+ the welcome screen and `#/canary`.
+- Backend: `ffidvfguojalzpclipzi` only. Never apply this fork's SQL to the production project. No
+ production users or storage were copied. The publishable Supabase key is a public client key.
+- Native source is kept for parity. Do not release it with the production application or signing
+ identity.
+- Leave `CANARY_MONAD` off (default). User-facing text names Solana only.
+- After changing the word list in `canary_fingerprint.dart`, run
+ `dart run tool/canary_fingerprint_harness.dart`; it must end with `FAILURES: 0`.
+- Run `flutter analyze` and `flutter test` before every commit. Do not reformat unrelated copied
+ files. Migrations are immutable; add new ones.
diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml
index f5306fb..ec9fbef 100644
--- a/app/analysis_options.yaml
+++ b/app/analysis_options.yaml
@@ -7,6 +7,15 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
+analyzer:
+ exclude:
+ - build/**
+ - android/**
+ - ios/**
+ - web/**
+ - windows/**
+ - macos/**
+ - linux/**
include: package:flutter_lints/flutter.yaml
linter:
diff --git a/app/lib/features/monad/presentation/experiment_frame.dart b/app/lib/components/experiment_frame.dart
similarity index 90%
rename from app/lib/features/monad/presentation/experiment_frame.dart
rename to app/lib/components/experiment_frame.dart
index 56f988c..04d5887 100644
--- a/app/lib/features/monad/presentation/experiment_frame.dart
+++ b/app/lib/components/experiment_frame.dart
@@ -14,7 +14,7 @@ class ExperimentFrame extends StatelessWidget {
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 7),
child: Center(
child: Text(
- 'NO SUS · Monad testnet · use harmless test notes only',
+ 'NO SUS · experiment build · use harmless test notes only',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
diff --git a/app/lib/config/app_mode.dart b/app/lib/config/app_mode.dart
index fabeeae..23551cf 100644
--- a/app/lib/config/app_mode.dart
+++ b/app/lib/config/app_mode.dart
@@ -3,7 +3,17 @@
/// The full NO SUS app (APK, main web app) never sets it.
const bool kCanaryOnly = bool.fromEnvironment('NOSUS_CANARY_ONLY');
-/// Optional deployed contract address to show on the Canary landing page.
+/// Shows the Monad testnet prototype in NO SUS Canary: the wallet button,
+/// Monad proof chips, "Verify on Monad" and all Monad wording. Off by
+/// default: Solana devnet is the only receipt the product shows. Build with
+/// --dart-define=CANARY_MONAD=true to reproduce the earlier Monad build.
+const bool kCanaryMonad = bool.fromEnvironment(
+ 'CANARY_MONAD',
+ defaultValue: false,
+);
+
+/// Optional deployed contract address to show on the Canary landing page
+/// (Monad testnet; only shown when [kCanaryMonad] is true).
const String kCanaryContract = String.fromEnvironment(
'CANARY_CONTRACT',
defaultValue: '0xb1a1858866122c84cf97861ca815fb070e010e68',
@@ -12,5 +22,5 @@ const String kCanaryContract = String.fromEnvironment(
/// Optional repo URL for the open-source link in the hero/footer.
const String kCanaryRepoUrl = String.fromEnvironment(
'CANARY_REPO_URL',
- defaultValue: 'https://github.com/https-shubhamsahu/nosus-monad',
+ defaultValue: 'https://github.com/https-shubhamsahu/nosus-canary',
);
diff --git a/app/lib/features/canary/data/canary_api.dart b/app/lib/features/canary/data/canary_api.dart
index eb04638..10ca767 100644
--- a/app/lib/features/canary/data/canary_api.dart
+++ b/app/lib/features/canary/data/canary_api.dart
@@ -68,8 +68,12 @@ class CanaryApi {
return decoded;
}
- /// Uploads every encrypted copy and seals the note on Monad.
- Future<({String sealTxHash, DateTime expiresAt})> create({
+ /// Uploads every encrypted copy and seals the note: one memo on Solana
+ /// devnet ([sealSolanaSig]) and, only while the server has the Monad
+ /// prototype switched on, one Monad testnet transaction ([sealTxHash]).
+ /// The server answers 200 only once at least one of them exists.
+ Future<({String? sealTxHash, String? sealSolanaSig, DateTime expiresAt})>
+ create({
required String noteId,
required String ownerHash,
required int copyCount,
@@ -89,8 +93,17 @@ class CanaryApi {
{'index': c.index, 'ciphertext': c.ciphertext, 'iv': c.iv},
],
}, timeout: const Duration(seconds: 60));
+ final sealTxHash = result['seal_tx_hash'] as String?;
+ final sealSolanaSig = result['seal_solana_sig'] as String?;
+ if (sealTxHash == null && sealSolanaSig == null) {
+ throw const CanaryApiException(
+ 'The note was not sealed. Try again.',
+ retryable: true,
+ );
+ }
return (
- sealTxHash: result['seal_tx_hash'] as String,
+ sealTxHash: sealTxHash,
+ sealSolanaSig: sealSolanaSig,
expiresAt: DateTime.parse(result['expires_at'] as String),
);
}
@@ -103,6 +116,7 @@ class CanaryApi {
String ciphertext,
String iv,
String? openTxHash,
+ String? openSolanaSig,
DateTime openedAt,
})
>
@@ -123,6 +137,7 @@ class CanaryApi {
ciphertext: r['ciphertext'] as String,
iv: r['iv'] as String,
openTxHash: r['open_tx_hash'] as String?,
+ openSolanaSig: r['open_solana_sig'] as String?,
openedAt: DateTime.parse(r['opened_at'] as String),
);
}
@@ -140,6 +155,7 @@ class CanaryApi {
return CanaryNoteStatus(
copyCount: r['copy_count'] as int,
sealTxHash: r['seal_tx_hash'] as String?,
+ sealSolanaSig: r['seal_solana_sig'] as String?,
expiresAt: DateTime.parse(r['expires_at'] as String),
copies: [
for (final c in r['copies'] as List)
@@ -148,6 +164,7 @@ class CanaryApi {
readerName: c['reader_name'] as String,
openedAt: DateTime.parse(c['opened_at'] as String),
openTxHash: c['open_tx_hash'] as String?,
+ openSolanaSig: c['open_solana_sig'] as String?,
),
],
);
diff --git a/app/lib/features/canary/data/canary_crypto.dart b/app/lib/features/canary/data/canary_crypto.dart
index a8364e9..83eb68b 100644
--- a/app/lib/features/canary/data/canary_crypto.dart
+++ b/app/lib/features/canary/data/canary_crypto.dart
@@ -46,7 +46,7 @@ class CanaryCrypto {
static String copyDigest(String saltHex, int index, String copyText) =>
sha256Hex('$saltHex:$index:$copyText');
- /// Commitment to every copy, written to Monad: sha256 of the digests
+ /// Commitment to every copy, written on chain: sha256 of the digests
/// concatenated in copy order, as 0x-prefixed hex.
static String copiesHash(List copyDigests) =>
'0x${sha256Hex(copyDigests.join())}';
diff --git a/app/lib/features/canary/data/canary_repository.dart b/app/lib/features/canary/data/canary_repository.dart
index 6cab315..698c597 100644
--- a/app/lib/features/canary/data/canary_repository.dart
+++ b/app/lib/features/canary/data/canary_repository.dart
@@ -1,5 +1,6 @@
import 'package:uuid/uuid.dart';
+import '../../../config/app_mode.dart';
import '../../../core/utils/web_links.dart';
import '../domain/canary_fingerprint.dart';
import '../domain/canary_link.dart';
@@ -33,7 +34,9 @@ class CanaryRepository {
CanaryOwnerRecord? findNote(String noteId) => _store.findNote(noteId);
/// Sender: builds every copy on this device, uploads only ciphertext, and
- /// seals the note on Monad. [onStage] receives short progress messages.
+ /// seals the note on Solana devnet (plus Monad testnet when the server has
+ /// the prototype on).
+ /// [onStage] receives short progress messages.
Future createNote({
required String text,
required int copyCount,
@@ -118,7 +121,9 @@ class CanaryRepository {
// the codewords. Removed again below if the upload fails.
await _store.saveNote(draft);
- onStage?.call('Sealing on Monad…');
+ onStage?.call(
+ kCanaryMonad ? 'Sealing on Monad + Solana…' : 'Sealing on Solana devnet…',
+ );
try {
final result = await _api.create(
noteId: noteId,
@@ -130,6 +135,7 @@ class CanaryRepository {
);
final sealed = draft.copyWith(
sealTxHash: result.sealTxHash,
+ sealSolanaSig: result.sealSolanaSig,
expiresAt: result.expiresAt,
);
await _store.saveNote(sealed);
@@ -177,6 +183,7 @@ class CanaryRepository {
readerName: name,
openedAt: result.openedAt,
openTxHash: result.openTxHash,
+ openSolanaSig: result.openSolanaSig,
);
}
diff --git a/app/lib/features/canary/domain/canary_link.dart b/app/lib/features/canary/domain/canary_link.dart
index a889830..8b6067b 100644
--- a/app/lib/features/canary/domain/canary_link.dart
+++ b/app/lib/features/canary/domain/canary_link.dart
@@ -50,7 +50,8 @@ CanaryLinkToken? parseCanaryReaderLink(String fullUrl) {
return CanaryLinkToken(noteId: noteId, keyHex: key);
}
-/// The bytes32 note id used on Monad: sha256("nosus-canary:" + noteId).
+/// The bytes32 note id used on chain (Solana memo, Monad contract):
+/// sha256("nosus-canary:" + noteId).
/// The edge function computes the same value; keep them identical.
String canaryChainNoteId(String noteId) =>
'0x${sha256.convert(utf8.encode('nosus-canary:$noteId'))}';
@@ -58,3 +59,25 @@ String canaryChainNoteId(String noteId) =>
/// Public block explorer page for a Monad testnet transaction.
String canaryExplorerTxUrl(String txHash) =>
'https://testnet.monadscan.com/tx/$txHash';
+
+/// Public block explorer page for a Solana devnet transaction signature.
+String canarySolanaExplorerTxUrl(String signature) =>
+ 'https://explorer.solana.com/tx/$signature?cluster=devnet';
+
+/// Which public chain a Canary proof lives on.
+enum CanaryChain {
+ monad,
+ solana;
+
+ /// Short brand name for chips: 'Monad' / 'Solana'.
+ String get label => this == CanaryChain.monad ? 'Monad' : 'Solana';
+
+ /// Network name for sentences: 'Monad testnet' / 'Solana devnet'.
+ String get network =>
+ this == CanaryChain.monad ? 'Monad testnet' : 'Solana devnet';
+
+ /// Public explorer page for a transaction on this chain.
+ String txUrl(String hash) => this == CanaryChain.monad
+ ? canaryExplorerTxUrl(hash)
+ : canarySolanaExplorerTxUrl(hash);
+}
diff --git a/app/lib/features/canary/domain/canary_models.dart b/app/lib/features/canary/domain/canary_models.dart
index 1693625..f7300a5 100644
--- a/app/lib/features/canary/domain/canary_models.dart
+++ b/app/lib/features/canary/domain/canary_models.dart
@@ -27,6 +27,7 @@ class CanaryOwnerRecord {
required this.plan,
required this.codewords,
this.sealTxHash,
+ this.sealSolanaSig,
});
final String noteId;
@@ -49,26 +50,37 @@ class CanaryOwnerRecord {
/// codewords[i] is the 0/1 choice per slot for copy i.
final List> codewords;
- /// Monad transaction that sealed the note. Null until sealing succeeded.
+ /// Monad testnet transaction that sealed the note (earlier prototype).
+ /// Null while the server has Monad off, and for unsealed drafts.
final String? sealTxHash;
- bool get isSealed => sealTxHash != null;
-
- CanaryOwnerRecord copyWith({String? sealTxHash, DateTime? expiresAt}) =>
- CanaryOwnerRecord(
- noteId: noteId,
- keyHex: keyHex,
- ownerSecret: ownerSecret,
- saltHex: saltHex,
- copyCount: copyCount,
- createdAt: createdAt,
- expiresAt: expiresAt ?? this.expiresAt,
- readerLink: readerLink,
- preview: preview,
- plan: plan,
- codewords: codewords,
- sealTxHash: sealTxHash ?? this.sealTxHash,
- );
+ /// Solana devnet memo signature that sealed the note: the product's
+ /// receipt. Null for unsealed drafts and for records saved before Solana
+ /// proofs existed.
+ final String? sealSolanaSig;
+
+ /// Sealed once either chain holds the seal.
+ bool get isSealed => sealTxHash != null || sealSolanaSig != null;
+
+ CanaryOwnerRecord copyWith({
+ String? sealTxHash,
+ String? sealSolanaSig,
+ DateTime? expiresAt,
+ }) => CanaryOwnerRecord(
+ noteId: noteId,
+ keyHex: keyHex,
+ ownerSecret: ownerSecret,
+ saltHex: saltHex,
+ copyCount: copyCount,
+ createdAt: createdAt,
+ expiresAt: expiresAt ?? this.expiresAt,
+ readerLink: readerLink,
+ preview: preview,
+ plan: plan,
+ codewords: codewords,
+ sealTxHash: sealTxHash ?? this.sealTxHash,
+ sealSolanaSig: sealSolanaSig ?? this.sealSolanaSig,
+ );
Map toJson() => {
'v': 1,
@@ -84,6 +96,7 @@ class CanaryOwnerRecord {
'plan': plan.toJson(),
'codewords': [for (final c in codewords) c.join()],
'sealTxHash': sealTxHash,
+ 'sealSolanaSig': sealSolanaSig,
};
factory CanaryOwnerRecord.fromJson(Map json) {
@@ -104,6 +117,8 @@ class CanaryOwnerRecord {
[for (final ch in (c as String).split('')) ch == '1' ? 1 : 0],
],
sealTxHash: json['sealTxHash'] as String?,
+ // Records saved before Solana proofs existed have no key here.
+ sealSolanaSig: json['sealSolanaSig'] as String?,
);
}
}
@@ -115,36 +130,52 @@ class CanaryCopyStatus {
required this.readerName,
required this.openedAt,
required this.openTxHash,
+ required this.openSolanaSig,
});
final int copyIndex;
final String readerName;
final DateTime openedAt;
- /// Null while the Monad record is still being written.
+ /// Monad testnet transaction for this open. Null while it is being
+ /// written, and always null while the server has Monad off.
final String? openTxHash;
+
+ /// Solana devnet memo signature for this open. Null while it is being
+ /// written (a failed memo is sent again on the reader's next open).
+ final String? openSolanaSig;
+
+ /// The open has a public receipt on at least one chain.
+ bool get isRecorded => openTxHash != null || openSolanaSig != null;
}
class CanaryNoteStatus {
const CanaryNoteStatus({
required this.copyCount,
required this.sealTxHash,
+ required this.sealSolanaSig,
required this.expiresAt,
required this.copies,
});
final int copyCount;
+
+ /// Monad testnet transaction that sealed the note. Null while the server
+ /// has Monad off.
final String? sealTxHash;
+
+ /// Solana devnet memo signature that sealed the note.
+ final String? sealSolanaSig;
final DateTime expiresAt;
/// Only copies that have been opened, ordered by copy index.
final List copies;
- /// Nothing more can change: every copy is opened and on Monad, or the
- /// link has expired. Screens stop polling at this point.
+ /// Nothing more can change: every copy is opened and recorded on chain,
+ /// or the link has expired. Screens stop polling at this point.
bool get isSettled =>
!expiresAt.isAfter(DateTime.now()) ||
- (copies.length >= copyCount && copies.every((c) => c.openTxHash != null));
+ (copies.length >= copyCount && copies.every((c) => c.isRecorded));
CanaryCopyStatus? copy(int index) {
for (final c in copies) {
@@ -163,6 +194,7 @@ class CanaryOpenedCopy {
required this.readerName,
required this.openedAt,
required this.openTxHash,
+ required this.openSolanaSig,
});
/// The copy text, including its invisible marker.
@@ -172,7 +204,12 @@ class CanaryOpenedCopy {
final String readerName;
final DateTime openedAt;
- /// Null only in the rare case the Monad write succeeded but its hash was
- /// not stored; the UI then shows the time without a proof link.
+ /// Monad testnet transaction for this open (earlier prototype). Null
+ /// while the server has Monad off.
final String? openTxHash;
+
+ /// Solana devnet memo signature for this open. Null when the memo has not
+ /// landed yet (it is sent again on the next open of this copy); the UI
+ /// then shows the time without a proof link.
+ final String? openSolanaSig;
}
diff --git a/app/lib/features/canary/presentation/canary_composer_screen.dart b/app/lib/features/canary/presentation/canary_composer_screen.dart
index 227e0f2..5ea9154 100644
--- a/app/lib/features/canary/presentation/canary_composer_screen.dart
+++ b/app/lib/features/canary/presentation/canary_composer_screen.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../../config/app_mode.dart';
import '../../../core/layout/app_breakpoints.dart';
import '../../../theme.dart';
import '../data/canary_api.dart';
@@ -455,10 +456,15 @@ class _CanaryComposerScreenState extends ConsumerState {
),
const SizedBox(height: 16),
const Text(
- 'Your note is encrypted on this device. NO SUS stores only the '
- 'encrypted copies; the key travels inside the link. Monad testnet '
- 'receives only a hash of the copies and an anonymous tag per '
- 'reader, never the text or names.',
+ kCanaryMonad
+ ? 'Your note is encrypted on this device. NO SUS stores only the '
+ 'encrypted copies; the key travels inside the link. Monad testnet '
+ 'and Solana devnet receive only a hash of the copies and an '
+ 'anonymous tag per reader, never the text or names.'
+ : 'Your note is encrypted on this device. NO SUS stores only the '
+ 'encrypted copies; the key travels inside the link. Solana '
+ 'devnet receives only a hash of the copies and an anonymous '
+ 'tag per reader, never the text or names.',
style: TextStyle(
fontSize: 13,
height: 1.55,
diff --git a/app/lib/features/canary/presentation/canary_created_screen.dart b/app/lib/features/canary/presentation/canary_created_screen.dart
index 8d764bf..325d9de 100644
--- a/app/lib/features/canary/presentation/canary_created_screen.dart
+++ b/app/lib/features/canary/presentation/canary_created_screen.dart
@@ -13,8 +13,8 @@ import '../domain/canary_models.dart';
import 'canary_note_screen.dart';
import 'canary_providers.dart';
import 'canary_ui.dart';
-import 'ui/chain_chip.dart';
import 'ui/glow_card.dart';
+import 'ui/proof_chips.dart';
import 'ui/swap_word_text.dart';
/// Shown right after creating a note, and from the dashboard's "Show QR".
@@ -219,9 +219,13 @@ class _CanaryCreatedScreenState extends ConsumerState {
),
],
),
- if (record.sealTxHash != null) ...[
+ if (record.sealTxHash != null || record.sealSolanaSig != null) ...[
const SizedBox(height: 12),
- ChainChip(label: 'Sealed on Monad', txHash: record.sealTxHash),
+ ProofChips(
+ monadTxHash: record.sealTxHash,
+ solanaSig: record.sealSolanaSig,
+ labelPrefix: 'Sealed on',
+ ),
],
],
);
diff --git a/app/lib/features/canary/presentation/canary_home_screen.dart b/app/lib/features/canary/presentation/canary_home_screen.dart
index e4d2f52..b162e23 100644
--- a/app/lib/features/canary/presentation/canary_home_screen.dart
+++ b/app/lib/features/canary/presentation/canary_home_screen.dart
@@ -94,14 +94,23 @@ class _CanaryHomeScreenState extends ConsumerState {
static const double _maxWide = 1360;
- static const _tickerItems = [
- 'EVERY OPEN = 1 MONAD TRANSACTION',
- '~0.015 MON PER READER',
- 'FINAL IN UNDER 1 SECOND',
- 'NO WALLET NEEDED TO READ',
- 'LEAK MATCHING STAYS ON YOUR DEVICE',
- 'IT CANNOT STOP SCREENSHOTS. IT MAKES THEM TRACEABLE.',
- ];
+ static const _tickerItems = kCanaryMonad
+ ? [
+ 'EVERY OPEN = 1 TX ON MONAD + 1 ON SOLANA',
+ '~0.015 MON PER READER',
+ 'FINAL IN UNDER 1 SECOND',
+ 'NO WALLET NEEDED TO READ',
+ 'LEAK MATCHING STAYS ON YOUR DEVICE',
+ 'IT CANNOT STOP SCREENSHOTS. IT MAKES THEM TRACEABLE.',
+ ]
+ : [
+ 'EVERY OPEN = 1 MEMO ON SOLANA DEVNET',
+ '~0.000005 SOL PER READER',
+ 'ONLY HASHES GO ON CHAIN, NEVER TEXT',
+ 'NO WALLET NEEDED TO READ',
+ 'LEAK MATCHING STAYS ON YOUR DEVICE',
+ 'IT CANNOT STOP SCREENSHOTS. IT MAKES THEM TRACEABLE.',
+ ];
Widget _sectionTitle(String title, String subtitle) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -456,14 +465,21 @@ class _CanaryHomeScreenState extends ConsumerState {
borderRadius: BorderRadius.circular(8),
border: Border.all(color: CanaryTokens.text, width: 2),
),
- child: const Text(
- 'NO SUS x MONAD',
- style: TextStyle(
+ // One line on wide screens; on a phone the three names of the
+ // Monad build would not fit in 320 px, so Solana takes a second
+ // line in the stamp.
+ child: Text(
+ !kCanaryMonad
+ ? 'NO SUS x SOLANA'
+ : expanded
+ ? 'NO SUS x MONAD x SOLANA'
+ : 'NO SUS x MONAD\nx SOLANA',
+ style: const TextStyle(
color: CanaryTokens.text,
fontFamily: CanaryTokens.monoFont,
fontSize: 22,
letterSpacing: 2,
- height: 1,
+ height: 1.1,
),
),
),
@@ -561,14 +577,19 @@ class _CanaryHomeScreenState extends ConsumerState {
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
- if (kCanaryContract.isNotEmpty)
- const ChainChip(
- label: 'Contract on Monad',
- txHash: kCanaryContract,
- compact: true,
- ),
- const Text('~0.015 MON / READER', style: stat),
- const Text('FINAL < 1 S', style: stat),
+ if (kCanaryMonad) ...[
+ if (kCanaryContract.isNotEmpty)
+ const ChainChip(
+ label: 'Contract on Monad',
+ txHash: kCanaryContract,
+ compact: true,
+ ),
+ const Text('~0.015 MON / READER', style: stat),
+ const Text('FINAL < 1 S', style: stat),
+ ] else ...[
+ const Text('SOLANA DEVNET', style: stat),
+ const Text('~0.000005 SOL / READER', style: stat),
+ ],
InkWell(
onTap: () => _openUrl(kCanaryRepoUrl),
child: const Text(
diff --git a/app/lib/features/canary/presentation/canary_note_screen.dart b/app/lib/features/canary/presentation/canary_note_screen.dart
index 431fc8c..6ee6431 100644
--- a/app/lib/features/canary/presentation/canary_note_screen.dart
+++ b/app/lib/features/canary/presentation/canary_note_screen.dart
@@ -2,17 +2,20 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:url_launcher/url_launcher.dart';
+import '../../../config/app_mode.dart';
import '../../../core/layout/app_breakpoints.dart';
import '../../../theme.dart';
import '../data/canary_api.dart';
+import '../domain/canary_link.dart';
import '../domain/canary_models.dart';
import 'canary_created_screen.dart';
import 'canary_leak_check_screen.dart';
import 'canary_providers.dart';
import 'canary_ui.dart';
-import 'ui/chain_chip.dart';
import 'ui/glow_card.dart';
+import 'ui/proof_chips.dart';
import 'ui/wallet_button.dart';
/// Owner dashboard: live "Seen by" list plus the leak check.
@@ -73,6 +76,25 @@ class _CanaryNoteScreenState extends ConsumerState {
}
}
+ Future _openSolanaSeal(String signature) async {
+ final ok = await launchUrl(
+ Uri.parse(canarySolanaExplorerTxUrl(signature)),
+ mode: LaunchMode.externalApplication,
+ );
+ if (!ok && mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Could not open the explorer.')),
+ );
+ }
+ }
+
+ /// Solana is on for this note when its seal landed there; an open with no
+ /// signature yet is then "still being written" for a short while.
+ bool _solanaPending(CanaryNoteStatus status, CanaryCopyStatus copy) =>
+ status.sealSolanaSig != null &&
+ copy.openSolanaSig == null &&
+ DateTime.now().difference(copy.openedAt) < const Duration(minutes: 2);
+
@override
Widget build(BuildContext context) {
final record = _record;
@@ -180,10 +202,21 @@ class _CanaryNoteScreenState extends ConsumerState {
),
),
),
- VerifyOnMonadButton(
- noteId: record.noteId,
- localOpened: status?.copies.length,
- ),
+ if (kCanaryMonad)
+ VerifyOnMonadButton(
+ noteId: record.noteId,
+ localOpened: status?.copies.length,
+ ),
+ if (status?.sealSolanaSig case final sig?)
+ OutlinedButton.icon(
+ icon: const Icon(Icons.open_in_new, size: 18),
+ label: const Text(
+ 'Verify on Solana ↗',
+ semanticsLabel: 'Verify on Solana. Opens the seal on '
+ 'Solana devnet in the explorer.',
+ ),
+ onPressed: () => _openSolanaSeal(sig),
+ ),
OutlinedButton.icon(
icon: const Icon(Icons.qr_code),
label: const Text('Show link & QR'),
@@ -214,6 +247,7 @@ class _CanaryNoteScreenState extends ConsumerState {
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundColor: CanaryTokens.surfaceHi,
@@ -233,25 +267,19 @@ class _CanaryNoteScreenState extends ConsumerState {
color: CanaryTokens.textDim,
),
),
+ const SizedBox(height: 8),
+ // Below the name, not beside it: two chips need the
+ // card's full width to wrap on a phone.
+ ProofChips(
+ monadTxHash: copy.openTxHash,
+ solanaSig: copy.openSolanaSig,
+ monadPending: copy.openTxHash == null,
+ solanaPending: _solanaPending(status, copy),
+ compact: true,
+ ),
],
),
),
- if (copy.openTxHash == null)
- const Tooltip(
- message: 'Recording on Monad…',
- child: Icon(
- Icons.hourglass_empty,
- color: CanaryTokens.monad,
- ),
- )
- else
- Flexible(
- child: ChainChip(
- label: 'Monad',
- txHash: copy.openTxHash,
- compact: true,
- ),
- ),
],
),
),
diff --git a/app/lib/features/canary/presentation/canary_reader_screen.dart b/app/lib/features/canary/presentation/canary_reader_screen.dart
index 3cdcd1d..3f615a2 100644
--- a/app/lib/features/canary/presentation/canary_reader_screen.dart
+++ b/app/lib/features/canary/presentation/canary_reader_screen.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../../config/app_mode.dart';
import '../../../core/layout/app_breakpoints.dart';
import '../../../theme.dart';
import '../data/canary_api.dart';
@@ -8,11 +9,11 @@ import '../domain/canary_models.dart';
import 'canary_providers.dart';
import 'canary_ui.dart';
import 'ui/canary_envelope.dart';
-import 'ui/chain_chip.dart';
import 'ui/copy_badge.dart';
import 'ui/glow_card.dart';
import 'ui/liquid_carve_button.dart';
import 'ui/pixel_canary.dart';
+import 'ui/proof_chips.dart';
import 'ui/canary_strip.dart';
/// Standalone app for the web reader path (main.dart runs it directly for
@@ -273,8 +274,13 @@ class _CanaryReaderScreenState extends ConsumerState {
),
const SizedBox(height: 16),
const Text(
- 'The sender sees your name and when you opened your copy. Monad '
- 'testnet records that a copy was opened, without your name.',
+ kCanaryMonad
+ ? 'The sender sees your name and when you opened your copy. Monad '
+ 'testnet and Solana devnet each record that a copy was opened, '
+ 'without your name.'
+ : 'The sender sees your name and when you opened your copy. '
+ 'Solana devnet records that a copy was opened, without '
+ 'your name.',
style: TextStyle(
fontSize: 14,
height: 1.55,
@@ -316,14 +322,21 @@ class _CanaryReaderScreenState extends ConsumerState {
const SizedBox(height: 12),
DelayedFadeIn(
delay: const Duration(milliseconds: 450),
- child: copy.openTxHash == null
+ // No polling here, so no pending chip: show the proofs that
+ // exist, or the time alone when no shown proof was stored.
+ child:
+ copy.openSolanaSig == null &&
+ (!kCanaryMonad || copy.openTxHash == null)
? Text(
- 'Recorded on Monad testnet at ${CanaryUi.clock(copy.openedAt)}',
+ kCanaryMonad
+ ? 'Recorded on Monad testnet at ${CanaryUi.clock(copy.openedAt)}'
+ : 'Opened at ${CanaryUi.clock(copy.openedAt)}',
style: const TextStyle(fontFamily: CanaryTokens.monoFont),
)
- : ChainChip(
- label: 'Recorded on Monad',
- txHash: copy.openTxHash,
+ : ProofChips(
+ monadTxHash: copy.openTxHash,
+ solanaSig: copy.openSolanaSig,
+ labelPrefix: 'Recorded on',
),
),
],
@@ -337,7 +350,9 @@ class _BrandSide extends StatelessWidget {
static const _facts = [
'YOUR COPY IS UNIQUE TO YOU',
- 'OPENING IT = 1 MONAD TRANSACTION',
+ kCanaryMonad
+ ? 'OPENING IT = 1 TX ON MONAD + 1 ON SOLANA'
+ : 'OPENING IT = 1 MEMO ON SOLANA DEVNET',
'NO WALLET, NO APP, NO SIGN-UP',
];
@@ -363,7 +378,9 @@ class _BrandSide extends StatelessWidget {
),
const SizedBox(height: 14),
const Text(
- ' Your own copy. Recorded on Monad. ',
+ kCanaryMonad
+ ? ' Your own copy. Recorded on Monad + Solana. '
+ : ' Your own copy. Recorded on Solana. ',
style: TextStyle(
fontFamily: CanaryTokens.displayFont,
fontSize: 26,
diff --git a/app/lib/features/canary/presentation/canary_ui.dart b/app/lib/features/canary/presentation/canary_ui.dart
index 20fac40..b09ab51 100644
--- a/app/lib/features/canary/presentation/canary_ui.dart
+++ b/app/lib/features/canary/presentation/canary_ui.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
+import '../../../config/app_mode.dart';
import '../../../core/layout/app_breakpoints.dart';
import '../../../theme.dart';
import '../domain/canary_link.dart';
@@ -97,7 +98,8 @@ class CanaryUi {
),
actions: [
...?actions,
- const WalletButton(compact: true),
+ if (kCanaryMonad)
+ const WalletButton(compact: true),
const SizedBox(width: 8),
],
),
diff --git a/app/lib/features/canary/presentation/ui/canary_envelope.dart b/app/lib/features/canary/presentation/ui/canary_envelope.dart
index 50cb8f1..3e995b5 100644
--- a/app/lib/features/canary/presentation/ui/canary_envelope.dart
+++ b/app/lib/features/canary/presentation/ui/canary_envelope.dart
@@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
+import '../../../../config/app_mode.dart';
import '../../../../theme.dart';
import 'canary_mark.dart';
import 'glow_card.dart';
@@ -18,7 +19,7 @@ class BobbingCanaryMark extends StatelessWidget {
Widget build(BuildContext context) => PixelCanary(size: size);
}
-/// Sealed envelope shown while the copy is assigned on Monad.
+/// Sealed envelope shown while the copy is assigned and recorded on chain.
class CanaryEnvelope extends StatefulWidget {
const CanaryEnvelope({super.key});
@@ -67,7 +68,9 @@ class _CanaryEnvelopeState extends State
const CanaryMark(size: 56),
const SizedBox(height: 16),
Text(
- 'Making your copy… recording it on Monad',
+ kCanaryMonad
+ ? 'Making your copy… recording it on Monad'
+ : 'Making your copy… recording it on Solana',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
diff --git a/app/lib/features/canary/presentation/ui/canary_nav_bar.dart b/app/lib/features/canary/presentation/ui/canary_nav_bar.dart
index 54bff8d..d9095f9 100644
--- a/app/lib/features/canary/presentation/ui/canary_nav_bar.dart
+++ b/app/lib/features/canary/presentation/ui/canary_nav_bar.dart
@@ -97,16 +97,19 @@ class CanaryTopBar extends StatelessWidget implements PreferredSizeWidget {
],
),
- // Right side: Monad pulse pill + NO SUS app link
+ // Right side: network pulse pill (+ Monad wallet when the
+ // CANARY_MONAD build flag is on) + NO SUS app link
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showPill) ...[
- const MonadTestnetPill(),
+ const NetworkPill(),
const SizedBox(width: 14),
],
- WalletButton(compact: !expanded),
- const SizedBox(width: 12),
+ if (kCanaryMonad) ...[
+ WalletButton(compact: !expanded),
+ const SizedBox(width: 12),
+ ],
if (standalone)
expanded
? ConstrainedBox(
@@ -239,15 +242,23 @@ class _NavLinkState extends State<_NavLink> {
}
}
-/// Pulsing "Monad testnet" indicator pill.
-class MonadTestnetPill extends StatefulWidget {
- const MonadTestnetPill({super.key});
+/// Name of the network Canary proofs land on, for pills and strips.
+/// "SOLANA DEVNET", or "MONAD TESTNET" in a CANARY_MONAD build.
+const String kCanaryNetworkLabel = kCanaryMonad
+ ? 'MONAD TESTNET'
+ : 'SOLANA DEVNET';
+
+/// Pulsing network indicator pill ([kCanaryNetworkLabel]).
+class NetworkPill extends StatefulWidget {
+ const NetworkPill({super.key, this.label = kCanaryNetworkLabel});
+
+ final String label;
@override
- State createState() => _MonadTestnetPillState();
+ State createState() => _NetworkPillState();
}
-class _MonadTestnetPillState extends State
+class _NetworkPillState extends State
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@@ -307,9 +318,9 @@ class _MonadTestnetPillState extends State
},
),
const SizedBox(width: 8),
- const Text(
- 'MONAD TESTNET',
- style: TextStyle(
+ Text(
+ widget.label,
+ style: const TextStyle(
fontFamily: CanaryTokens.monoFont,
fontSize: 21,
letterSpacing: 1,
@@ -443,11 +454,13 @@ class CanaryFooter extends StatelessWidget {
spacing: 12,
runSpacing: 8,
children: [
- const Text(
- 'Built at Monad Blitz Mumbai V4',
- style: TextStyle(color: CanaryTokens.textDim, fontSize: 13),
- ),
- const Text('·', style: TextStyle(color: CanaryTokens.textDim)),
+ if (kCanaryMonad) ...[
+ const Text(
+ 'Built at Monad Blitz Mumbai V4',
+ style: TextStyle(color: CanaryTokens.textDim, fontSize: 13),
+ ),
+ const Text('·', style: TextStyle(color: CanaryTokens.textDim)),
+ ],
InkWell(
onTap: () => _openUrl(context, 'https://nosus.foo'),
child: const Text(
diff --git a/app/lib/features/canary/presentation/ui/canary_reveal.dart b/app/lib/features/canary/presentation/ui/canary_reveal.dart
index 48e5f2a..a2ac73d 100644
--- a/app/lib/features/canary/presentation/ui/canary_reveal.dart
+++ b/app/lib/features/canary/presentation/ui/canary_reveal.dart
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
+import '../../../../config/app_mode.dart';
import '../../../../theme.dart';
import '../../domain/canary_fingerprint.dart';
+import '../../domain/canary_link.dart';
import '../../domain/canary_models.dart';
import '../canary_ui.dart';
import 'canary_mark.dart';
@@ -126,9 +128,16 @@ class _CanaryRevealState extends State
'Opened ${CanaryUi.clock(copy.openedAt)}',
style: const TextStyle(fontFamily: CanaryTokens.monoFont),
),
- if (copy.openTxHash != null) ...[
+ if (kCanaryMonad && copy.openTxHash != null) ...[
const SizedBox(height: 8),
ChainChip(label: 'Proof on Monad', txHash: copy.openTxHash),
+ ] else if (!kCanaryMonad && copy.openSolanaSig != null) ...[
+ const SizedBox(height: 8),
+ ChainChip(
+ chain: CanaryChain.solana,
+ label: 'Proof on Solana',
+ txHash: copy.openSolanaSig,
+ ),
],
],
const SizedBox(height: 8),
diff --git a/app/lib/features/canary/presentation/ui/canary_strip.dart b/app/lib/features/canary/presentation/ui/canary_strip.dart
index b9911cf..930c535 100644
--- a/app/lib/features/canary/presentation/ui/canary_strip.dart
+++ b/app/lib/features/canary/presentation/ui/canary_strip.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../../../theme.dart';
+import 'canary_nav_bar.dart' show kCanaryNetworkLabel;
/// Orange "testnet" strip across the top of the Canary web app.
class CanaryStrip extends StatelessWidget {
@@ -24,7 +25,7 @@ class CanaryStrip extends StatelessWidget {
child: const SafeArea(
bottom: false,
child: Text(
- 'NO SUS · MONAD TESTNET · USE HARMLESS TEST NOTES ONLY',
+ 'NO SUS · $kCanaryNetworkLabel · USE HARMLESS TEST NOTES ONLY',
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
diff --git a/app/lib/features/canary/presentation/ui/chain_chip.dart b/app/lib/features/canary/presentation/ui/chain_chip.dart
index b4cfb50..a1b76d8 100644
--- a/app/lib/features/canary/presentation/ui/chain_chip.dart
+++ b/app/lib/features/canary/presentation/ui/chain_chip.dart
@@ -4,33 +4,70 @@ import 'package:url_launcher/url_launcher.dart';
import '../../../../theme.dart';
import '../../domain/canary_link.dart';
-/// On-chain status pill. Monad purple only; hash is VT323.
+/// Ink and tint for one chain's chip. Monad keeps the orange paper tint with
+/// an ink border; Solana gets a single restrained teal ink (5.7:1 on the
+/// paper background) so the two proofs read apart without a brand gradient.
+class _ChainStyle {
+ const _ChainStyle({required this.ink, required this.tint});
+
+ final Color ink;
+ final Color tint;
+
+ static const Color _solanaInk = Color(0xFF0F6E63);
+
+ static _ChainStyle of(CanaryChain chain) => switch (chain) {
+ CanaryChain.monad => const _ChainStyle(
+ ink: CanaryTokens.monad,
+ tint: Color(0x40F28C28),
+ ),
+ CanaryChain.solana => const _ChainStyle(
+ ink: _solanaInk,
+ tint: Color(0x1F0F6E63),
+ ),
+ };
+}
+
+const TextStyle _chipText = TextStyle(
+ fontFamily: CanaryTokens.monoFont,
+ fontSize: 20,
+ color: CanaryTokens.text,
+ height: 1.2,
+);
+
+/// On-chain status pill. Border and icon take the chain's ink; hash is VT323.
class ChainChip extends StatelessWidget {
const ChainChip({
super.key,
required this.label,
this.txHash,
+ this.chain = CanaryChain.monad,
this.compact = false,
});
final String label;
final String? txHash;
+ final CanaryChain chain;
final bool compact;
+ /// `0x1234…abcd` for hex hashes, `5VfY…Qm2p` for base58 signatures.
static String shortHash(String hash) {
- final h = hash.startsWith('0x') ? hash.substring(2) : hash;
+ final hex = hash.startsWith('0x');
+ final h = hex ? hash.substring(2) : hash;
if (h.length < 10) return hash;
- return '0x${h.substring(0, 4)}…${h.substring(h.length - 4)}';
+ final short = '${h.substring(0, 4)}…${h.substring(h.length - 4)}';
+ return hex ? '0x$short' : short;
}
Future _open(BuildContext context) async {
final hash = txHash;
if (hash == null) return;
+ // A 42-char 0x value is a Monad address (the contract), not a tx.
+ final isMonadAddress = chain == CanaryChain.monad && hash.length == 42;
final ok = await launchUrl(
Uri.parse(
- hash.length == 42
+ isMonadAddress
? 'https://testnet.monadscan.com/address/$hash'
- : canaryExplorerTxUrl(hash),
+ : chain.txUrl(hash),
),
mode: LaunchMode.externalApplication,
);
@@ -44,14 +81,15 @@ class ChainChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hash = txHash;
+ final style = _ChainStyle.of(chain);
final text = hash == null ? label : '$label · ${shortHash(hash)}';
final chip = ConstrainedBox(
constraints: const BoxConstraints(minHeight: 48),
child: DecoratedBox(
decoration: BoxDecoration(
- color: const Color(0x40F28C28),
+ color: style.tint,
borderRadius: BorderRadius.circular(CanaryTokens.rChip),
- border: Border.all(color: CanaryTokens.monad),
+ border: Border.all(color: style.ink),
),
child: Padding(
padding: EdgeInsets.symmetric(
@@ -61,18 +99,9 @@ class ChainChip extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
- const Icon(Icons.link, size: 18, color: CanaryTokens.monad),
+ Icon(Icons.link, size: 18, color: style.ink),
const SizedBox(width: 8),
- Text(
- text,
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(
- fontFamily: CanaryTokens.monoFont,
- fontSize: 20,
- color: CanaryTokens.text,
- height: 1.2,
- ),
- ),
+ Text(text, overflow: TextOverflow.ellipsis, style: _chipText),
],
),
),
@@ -84,7 +113,7 @@ class ChainChip extends StatelessWidget {
}
return Semantics(
button: true,
- label: '$label. View proof on Monad testnet.',
+ label: '$label. View proof on ${chain.network}.',
child: Material(
color: Colors.transparent,
child: InkWell(
@@ -96,3 +125,59 @@ class ChainChip extends StatelessWidget {
);
}
}
+
+/// Same silhouette as [ChainChip] for a proof that is still being written:
+/// a 16 px progress ring plus "Monad…" / "Solana…". Not tappable.
+class PendingChainChip extends StatelessWidget {
+ const PendingChainChip({
+ super.key,
+ required this.chain,
+ this.compact = false,
+ });
+
+ final CanaryChain chain;
+ final bool compact;
+
+ @override
+ Widget build(BuildContext context) {
+ final style = _ChainStyle.of(chain);
+ final message = 'Recording on ${chain.network}…';
+ return Semantics(
+ label: message,
+ excludeSemantics: true,
+ child: Tooltip(
+ message: message,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(minHeight: 48),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(CanaryTokens.rChip),
+ border: Border.all(color: style.ink.withValues(alpha: 0.6)),
+ ),
+ child: Padding(
+ padding: EdgeInsets.symmetric(
+ horizontal: compact ? 12 : 16,
+ vertical: 10,
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: style.ink,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text('${chain.label}…', style: _chipText),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/app/lib/features/canary/presentation/ui/proof_chips.dart b/app/lib/features/canary/presentation/ui/proof_chips.dart
new file mode 100644
index 0000000..025793a
--- /dev/null
+++ b/app/lib/features/canary/presentation/ui/proof_chips.dart
@@ -0,0 +1,75 @@
+import 'package:flutter/material.dart';
+
+import '../../../../config/app_mode.dart';
+import '../../domain/canary_link.dart';
+import 'chain_chip.dart';
+
+/// The on-chain proofs for one seal or one open.
+///
+/// Solana devnet is the receipt: its chip shows whenever a signature exists.
+/// The Monad testnet chip (earlier prototype) shows only when [showMonad] is
+/// true, which defaults to the CANARY_MONAD build flag ([kCanaryMonad]).
+///
+/// A [ChainChip] is shown for every hash that exists. A null hash shows a
+/// [PendingChainChip] only when the matching `pending` flag is true, so a
+/// chain that is switched off simply does not appear. Chips wrap onto new
+/// rows instead of clipping on narrow screens.
+class ProofChips extends StatelessWidget {
+ const ProofChips({
+ super.key,
+ this.monadTxHash,
+ this.solanaSig,
+ this.monadPending = false,
+ this.solanaPending = false,
+ this.labelPrefix = '',
+ this.compact = false,
+ bool? showMonad,
+ }) : showMonad = showMonad ?? kCanaryMonad;
+
+ final String? monadTxHash;
+ final String? solanaSig;
+
+ /// Show a progress chip while the Monad record is still being written.
+ final bool monadPending;
+
+ /// Show a progress chip while the Solana record is still being written.
+ final bool solanaPending;
+
+ /// 'Sealed on' / 'Recorded on' / '' (bare chain name).
+ final String labelPrefix;
+ final bool compact;
+
+ /// Whether the Monad chip may appear at all.
+ final bool showMonad;
+
+ String _label(CanaryChain chain) =>
+ labelPrefix.isEmpty ? chain.label : '$labelPrefix ${chain.label}';
+
+ Widget? _chip(CanaryChain chain, String? hash, bool pending) {
+ if (hash != null) {
+ return ChainChip(
+ chain: chain,
+ label: _label(chain),
+ txHash: hash,
+ compact: compact,
+ );
+ }
+ if (pending) return PendingChainChip(chain: chain, compact: compact);
+ return null;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final chips = [
+ if (showMonad) _chip(CanaryChain.monad, monadTxHash, monadPending),
+ _chip(CanaryChain.solana, solanaSig, solanaPending),
+ ].nonNulls.toList();
+ if (chips.isEmpty) return const SizedBox.shrink();
+ return Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: chips,
+ );
+ }
+}
diff --git a/app/lib/features/monad/data/monad_rpc_repository.dart b/app/lib/features/monad/data/monad_rpc_repository.dart
deleted file mode 100644
index 82ea781..0000000
--- a/app/lib/features/monad/data/monad_rpc_repository.dart
+++ /dev/null
@@ -1,79 +0,0 @@
-import 'dart:convert';
-import 'package:http/http.dart' as http;
-import '../domain/monad_receipt.dart';
-import '../domain/monad_repository.dart';
-
-class MonadRpcRepository implements MonadRepository {
- MonadRpcRepository({
- http.Client? client,
- String? contractAddress,
- Uri? rpcUrl,
- }) : _client = client ?? http.Client(),
- _contract =
- contractAddress ??
- const String.fromEnvironment('MONAD_DROPS_CONTRACT'),
- _rpc = rpcUrl ?? Uri.parse('https://testnet-rpc.monad.xyz');
- final http.Client _client;
- final String _contract;
- final Uri _rpc;
-
- @override
- bool get isConfigured =>
- RegExp(r'^0x[0-9a-fA-F]{40}$').hasMatch(_contract) &&
- _contract.toLowerCase() != MonadReceipt.zeroAddress;
-
- Future _call(String method, List