From 1f43379491bfefc81eb32554090ef30ded5a4e8b Mon Sep 17 00:00:00 2001 From: https-shubhamsahu Date: Sat, 10 Oct 2026 17:22:18 +0530 Subject: [PATCH 1/2] feat(canary): Solana devnet receipts; Monad optional and hidden Every note seal and reader open is now one SPL Memo transaction on Solana devnet (hashes only), sent by the edge function's relayer, so readers still need no wallet. Monad becomes optional (off when CANARY_CONTRACT_ADDRESS is unset) and its UI is behind --dart-define=CANARY_MONAD (default off). - edge function: solana.ts (kit 8.4 + memo 0.15, HTTP polling confirm), gating.ts + offline tests, Buffer shim for the Supabase runtime - migration: seal_solana_sig / open_solana_sig, canary_assign_copy - app: Solana proof chips, Solana-only copy, null seal_tx_hash support - docs: STATUS + VERIFICATION evidence, Solana-first README, devnet spike Co-Authored-By: Claude Opus 5.5 --- README.md | 41 ++-- app/analysis_options.yaml | 9 + app/lib/config/app_mode.dart | 14 +- app/lib/features/canary/data/canary_api.dart | 23 +- .../features/canary/data/canary_crypto.dart | 2 +- .../canary/data/canary_repository.dart | 11 +- .../features/canary/domain/canary_link.dart | 25 +- .../features/canary/domain/canary_models.dart | 85 +++++-- .../presentation/canary_composer_screen.dart | 14 +- .../presentation/canary_created_screen.dart | 10 +- .../presentation/canary_home_screen.dart | 61 +++-- .../presentation/canary_note_screen.dart | 70 ++++-- .../presentation/canary_reader_screen.dart | 37 ++- .../canary/presentation/canary_ui.dart | 4 +- .../presentation/ui/canary_envelope.dart | 7 +- .../presentation/ui/canary_nav_bar.dart | 47 ++-- .../canary/presentation/ui/canary_reveal.dart | 11 +- .../canary/presentation/ui/canary_strip.dart | 3 +- .../canary/presentation/ui/chain_chip.dart | 123 ++++++++-- .../canary/presentation/ui/proof_chips.dart | 75 ++++++ app/supabase/functions/canary/gating.ts | 61 +++++ app/supabase/functions/canary/gating_test.ts | 83 +++++++ app/supabase/functions/canary/index.ts | 195 ++++++++++++--- app/supabase/functions/canary/solana.ts | 194 +++++++++++++++ .../20261010090000_nosus_canary_solana.sql | 80 ++++++ app/test/unit/canary_link_test.dart | 27 ++ app/test/unit/canary_models_test.dart | 151 ++++++++++++ app/test/widget/canary_app_test.dart | 15 ++ app/test/widget/proof_chips_test.dart | 232 ++++++++++++++++++ assets/readme/banner.svg | 2 +- assets/readme/story.svg | 6 +- docs/STATUS.md | 36 +++ docs/canary/VERIFICATION.md | 30 +++ readme.config.json | 6 +- supabase/README.md | 19 ++ tool/build-readme.mjs | 61 +++-- tool/solana-spike/.gitignore | 2 + tool/solana-spike/README.md | 39 +++ tool/solana-spike/send_memo.ts | 164 +++++++++++++ 39 files changed, 1855 insertions(+), 220 deletions(-) create mode 100644 app/lib/features/canary/presentation/ui/proof_chips.dart create mode 100644 app/supabase/functions/canary/gating.ts create mode 100644 app/supabase/functions/canary/gating_test.ts create mode 100644 app/supabase/functions/canary/solana.ts create mode 100644 app/supabase/migrations/20261010090000_nosus_canary_solana.sql create mode 100644 app/test/unit/canary_models_test.dart create mode 100644 app/test/widget/proof_chips_test.dart create mode 100644 tool/solana-spike/.gitignore create mode 100644 tool/solana-spike/README.md create mode 100644 tool/solana-spike/send_memo.ts diff --git a/README.md b/README.md index 2231a94..db2e906 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## Step 1: Watch -

Animation: the sender seals a note on Monad, five readers each receive a different copy, Priya's copy leaks, NO SUS scans it and the canary sings: copy 03 is Priya's.

+

Animation: the sender seals a note on Solana, five readers each receive a different copy, Priya's copy leaks, NO SUS scans it and the canary sings: copy 03 is Priya's.

## Step 2: Try it @@ -26,25 +26,26 @@

Battle text: A wild LEAK appeared! Canary used FINGERPRINT! It's super effective! It was Priya's copy, number 03.

-## Step 3: Why Monad +## Step 3: Why Solana -- **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. +- **One memo per seal and per open.** Sealing a note and every **Open my copy** each write one Memo-program transaction on Solana devnet. +- **Hashes only.** A memo carries a hashed note id, one hash of all copies and an anonymous reader tag. Never the text, the key or a name. +- **A link on every chip.** Each record has its own Solana Explorer link in the app. +- **No wallet for readers.** A relayer pays the fee, about **0.000005 SOL** per memo. ## Judge checklist | | What | Where | |---|---|---| | **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** | Solana devnet memo log | [View on Solana Explorer](https://explorer.solana.com/tx/2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ?cluster=devnet) | +| **Done** | Live transaction in the demo | Every seal and 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/) | +Earlier prototype: the same flow on Monad testnet ([contract](https://testnet.monadscan.com/address/0xb1a1858866122c84cf97861ca815fb070e010e68), [verified source](https://sourcify.dev/server/repo-ui/10143/0xb1a1858866122c84cf97861ca815fb070e010e68)), still in the repo behind the `CANARY_MONAD` build flag. + > **Honest limits:** it names *whose copy* leaked, not *who shared it*. It doesn't stop screenshots. It makes them traceable. ## More @@ -53,8 +54,8 @@ How the fingerprint works - **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. +- **Seal it.** Copies are encrypted in your browser. The key lives only in the link's `#fragment`. A seal memo puts one hash of all copies on Solana devnet before anyone reads. +- **Share it.** Each reader gets the next unused copy. An open memo records it on Solana 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. @@ -72,13 +73,13 @@ cd app flutter pub get flutter run -d chrome -# Contract -cd ../contracts -npm ci -npx hardhat test +# Edge function checks (offline) +cd .. +npx -y deno@2 check app/supabase/functions/canary/index.ts +npx -y deno@2 test app/supabase/functions/canary/gating_test.ts ``` -Own backend and contract: see [`docs/canary/NOSUS_CANARY_BUILD_PLAN.md`](docs/canary/NOSUS_CANARY_BUILD_PLAN.md). +Own backend: see [`docs/canary/NOSUS_CANARY_BUILD_PLAN.md`](docs/canary/NOSUS_CANARY_BUILD_PLAN.md).
@@ -88,9 +89,9 @@ Own backend and contract: see [`docs/canary/NOSUS_CANARY_BUILD_PLAN.md`](docs/ca %%{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")] + F -- "seal memo" --> M[("Memo program on Solana devnet")] R["Reader"] -- "open + name" --> F - F -- "openCopy" --> M + F -- "open memo" --> M F -- "their copy" --> R ``` @@ -112,10 +113,10 @@ Fingerprint test harness, 15-slot demo note: | Partial excerpts (30–70%) | named only when sure | 0 / 600 |
-Full technical write-up (setup, deployment, contract and relayers): [`docs/TECHNICAL_README.md`](docs/TECHNICAL_README.md) +Full technical write-up (setup, deployment and relayers): [`docs/TECHNICAL_README.md`](docs/TECHNICAL_README.md) --- -**Team:** Shubham Sahu · Built at **Monad Blitz Mumbai V4** · part of **NO SUS** +**Team:** Shubham Sahu · Part of **NO SUS**

Game over? Play again at the live app.

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/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/supabase/functions/canary/gating.ts b/app/supabase/functions/canary/gating.ts new file mode 100644 index 0000000..24f5b3f --- /dev/null +++ b/app/supabase/functions/canary/gating.ts @@ -0,0 +1,61 @@ +// Which proof chains a canary request writes to. Pure functions only (no env, +// no network) so the rules can be checked with `deno test` offline. +// +// Solana devnet is the product's receipt. Monad testnet is the earlier +// prototype and is on only while CANARY_CONTRACT_ADDRESS is set. + +/** Monad is on only when a contract address is configured (blank = off). */ +export function monadEnabled(contractAddress: string | null | undefined): boolean { + return (contractAddress ?? "").trim() !== ""; +} + +type SealFields = { seal_tx_hash?: string | null; seal_solana_sig?: string | null }; + +/** A note is sealed once either chain holds its seal. */ +export function isSealed(note: SealFields | null | undefined): boolean { + return !!note && (!!note.seal_tx_hash || !!note.seal_solana_sig); +} + +export type SealPlan = { + /** Send sealNote on Monad (in parallel with Solana, Monad authoritative). */ + monad: boolean; + /** Send the seal memo on Solana. */ + solana: boolean; + /** A failed Solana memo must fail the request (Solana is the only receipt). */ + solanaRequired: boolean; +}; + +export function planSeal(p: { + monadOn: boolean; + solanaOn: boolean; + sealTx: string | null; + sealSolana: string | null; +}): SealPlan { + const sealed = isSealed({ seal_tx_hash: p.sealTx, seal_solana_sig: p.sealSolana }); + const solana = p.solanaOn && !p.sealSolana; + return { + monad: p.monadOn && !p.sealTx, + solana, + solanaRequired: solana && !p.monadOn && !sealed, + }; +} + +export type OpenPlan = { + /** Send openCopy on Monad (only for notes sealed there). */ + monad: boolean; + /** Send the open memo on Solana. A failure is stored as null and retried. */ + solana: boolean; +}; + +export function planOpen(p: { + monadOn: boolean; + solanaOn: boolean; + noteSealTx: string | null; + openTx: string | null; + openSolana: string | null; +}): OpenPlan { + return { + monad: p.monadOn && !!p.noteSealTx && !p.openTx, + solana: p.solanaOn && !p.openSolana, + }; +} diff --git a/app/supabase/functions/canary/gating_test.ts b/app/supabase/functions/canary/gating_test.ts new file mode 100644 index 0000000..3ae9be8 --- /dev/null +++ b/app/supabase/functions/canary/gating_test.ts @@ -0,0 +1,83 @@ +// Offline checks for the proof-chain rules. Run from the repo root: +// npx -y deno@2 test app/supabase/functions/canary/gating_test.ts +import { assertEquals } from "jsr:@std/assert@1"; +import { isSealed, monadEnabled, planOpen, planSeal } from "./gating.ts"; + +const TX = "0x" + "a".repeat(64); +const SIG = "5".repeat(88); + +Deno.test("monadEnabled: only a non-blank contract address turns Monad on", () => { + assertEquals(monadEnabled(undefined), false); + assertEquals(monadEnabled(null), false); + assertEquals(monadEnabled(""), false); + assertEquals(monadEnabled(" "), false); + assertEquals(monadEnabled("0x" + "1".repeat(40)), true); +}); + +Deno.test("isSealed: either chain's seal counts", () => { + assertEquals(isSealed(null), false); + assertEquals(isSealed({ seal_tx_hash: null, seal_solana_sig: null }), false); + assertEquals(isSealed({ seal_tx_hash: TX, seal_solana_sig: null }), true); + assertEquals(isSealed({ seal_tx_hash: null, seal_solana_sig: SIG }), true); +}); + +Deno.test("planSeal: Solana only, new note -> Solana required", () => { + assertEquals( + planSeal({ monadOn: false, solanaOn: true, sealTx: null, sealSolana: null }), + { monad: false, solana: true, solanaRequired: true }, + ); +}); + +Deno.test("planSeal: Solana only, already sealed -> nothing to send", () => { + assertEquals( + planSeal({ monadOn: false, solanaOn: true, sealTx: null, sealSolana: SIG }), + { monad: false, solana: false, solanaRequired: false }, + ); + // Sealed on Monad by an older build: fill the memo quietly. + assertEquals( + planSeal({ monadOn: false, solanaOn: true, sealTx: TX, sealSolana: null }), + { monad: false, solana: true, solanaRequired: false }, + ); +}); + +Deno.test("planSeal: both on behaves as before (Monad authoritative)", () => { + assertEquals( + planSeal({ monadOn: true, solanaOn: true, sealTx: null, sealSolana: null }), + { monad: true, solana: true, solanaRequired: false }, + ); + assertEquals( + planSeal({ monadOn: true, solanaOn: true, sealTx: TX, sealSolana: null }), + { monad: false, solana: true, solanaRequired: false }, + ); + assertEquals( + planSeal({ monadOn: true, solanaOn: false, sealTx: null, sealSolana: null }), + { monad: true, solana: false, solanaRequired: false }, + ); +}); + +Deno.test("planOpen: Solana only never touches Monad", () => { + assertEquals( + planOpen({ monadOn: false, solanaOn: true, noteSealTx: TX, openTx: null, openSolana: null }), + { monad: false, solana: true }, + ); + assertEquals( + planOpen({ monadOn: false, solanaOn: true, noteSealTx: null, openTx: null, openSolana: SIG }), + { monad: false, solana: false }, + ); +}); + +Deno.test("planOpen: both on behaves as before", () => { + assertEquals( + planOpen({ monadOn: true, solanaOn: true, noteSealTx: TX, openTx: null, openSolana: null }), + { monad: true, solana: true }, + ); + assertEquals( + planOpen({ monadOn: true, solanaOn: true, noteSealTx: TX, openTx: TX, openSolana: null }), + { monad: false, solana: true }, + ); + // A note sealed only on Solana is never opened on Monad (the contract would revert). + assertEquals( + planOpen({ monadOn: true, solanaOn: true, noteSealTx: null, openTx: null, openSolana: null }), + { monad: false, solana: true }, + ); +}); diff --git a/app/supabase/functions/canary/index.ts b/app/supabase/functions/canary/index.ts index 373ae7f..10bd987 100644 --- a/app/supabase/functions/canary/index.ts +++ b/app/supabase/functions/canary/index.ts @@ -5,24 +5,46 @@ // // { action: "create", note_id, owner_hash, copy_count, copies_hash, // expires_in_hours, copies: [{ index, ciphertext, iv }] } -// -> { seal_tx_hash, expires_at } +// -> { seal_tx_hash, seal_solana_sig, expires_at } // { action: "open", note_id, device_id, reader_name } -// -> { copy_index, copy_count, ciphertext, iv, open_tx_hash, opened_at } +// -> { copy_index, copy_count, ciphertext, iv, open_tx_hash, open_solana_sig, opened_at } // { action: "status", note_id, owner_secret } -// -> { copy_count, seal_tx_hash, expires_at, -// copies: [{ copy_index, reader_name, opened_at, open_tx_hash }] } +// -> { copy_count, seal_tx_hash, seal_solana_sig, expires_at, +// copies: [{ copy_index, reader_name, opened_at, open_tx_hash, open_solana_sig }] } +// +// *_solana_sig is the Solana devnet memo signature. *_tx_hash is the Monad +// testnet transaction hash and stays null whenever Monad is off. // // Errors: { error: "", retry?: true } with a 4xx/5xx status. // // The server never sees note text or keys: copies arrive already encrypted -// and the key stays in the link fragment. Monad receives only +// and the key stays in the link fragment. The chains receive only // sha256-based ids and hashes (never names or text). // +// Proof chains (rules in gating.ts): +// - Solana devnet (solana.ts) is the receipt: one SPL Memo per seal and per +// open. With Monad off a note is sealed only once its Solana memo lands, so +// a failed seal memo fails `create` with retry: true. A failed open memo is +// stored as null and sent again on the next open of that copy. +// - Monad testnet is the earlier prototype, kept behind a switch: on only +// while CANARY_CONTRACT_ADDRESS is set. When on, it runs in parallel with +// Solana exactly as before (Monad authoritative; a Solana failure only +// leaves *_solana_sig null). When off: no viem calls, no relayer leases. +// - A note counts as sealed when seal_tx_hash OR seal_solana_sig is set. +// - With neither chain configured, create and open return 503. +// // Secrets (supabase secrets set ...): -// CANARY_CONTRACT_ADDRESS 0x… NoSusCanary on Monad testnet -// CANARY_RELAYER_KEYS comma-separated 0x… private keys (testnet only) // CANARY_TAG_SALT 64 hex chars, random, never shared -// MONAD_RPC_URL optional, default https://testnet-rpc.monad.xyz +// SOLANA_RELAYER_KEYS comma-separated devnet keypairs (base58 64-byte +// secret key or solana-keygen JSON array). Required +// while Monad is off; unset = Solana proof off and +// responses carry null. +// SOLANA_RPC_URL optional, default https://api.devnet.solana.com +// SOLANA_MEMO_PROGRAM optional, default MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr +// CANARY_CONTRACT_ADDRESS optional; 0x… NoSusCanary on Monad testnet. +// Unset or empty = Monad off. +// CANARY_RELAYER_KEYS Monad only: comma-separated 0x… private keys (testnet) +// MONAD_RPC_URL Monad only, default https://testnet-rpc.monad.xyz // SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are injected automatically. import { createClient, type SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.39.8"; @@ -34,6 +56,8 @@ import { type Hex, } from "npm:viem@2.56.5"; import { privateKeyToAccount } from "npm:viem@2.56.5/accounts"; +import { loadSolana, openMemo, sealMemo, sendSolanaMemo, type SolanaConfig } from "./solana.ts"; +import { isSealed, monadEnabled, planOpen, planSeal } from "./gating.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -113,8 +137,11 @@ type Chain = { rpcUrl: string; }; -function loadChain(): Chain { - const contract = requireEnv("CANARY_CONTRACT_ADDRESS").trim(); +// Null when Monad is off (CANARY_CONTRACT_ADDRESS unset or empty). Once an +// address is set, a bad Monad config is a 503 as before. +function loadChainOrNull(): Chain | null { + const contract = (Deno.env.get("CANARY_CONTRACT_ADDRESS") ?? "").trim(); + if (!monadEnabled(contract)) return null; if (!/^0x[0-9a-fA-F]{40}$/.test(contract)) { throw new HttpError(503, "canary is not configured (contract address)"); } @@ -132,6 +159,29 @@ function loadChain(): Chain { }; } +// A bad Solana config is logged and treated as "off". With Monad on, a failed +// memo is logged and stored as null; with Monad off, see handleCreate. +function loadSolanaOrNull(): SolanaConfig | null { + try { + return loadSolana(); + } catch (e) { + console.error("canary: solana is misconfigured", e); + return null; + } +} + +function requireProofChain(chain: Chain | null, solana: SolanaConfig | null) { + if (!chain && !solana) { + throw new HttpError(503, "canary is not configured (no proof chain)"); + } +} + +function solanaSig(result: PromiseSettledResult, label: string): string | null { + if (result.status === "fulfilled") return result.value; + console.error(`${label}: solana memo failed`, result.reason); + return null; +} + // Sends one contract call from a leased relayer key and waits for success. // A key is leased for the whole send+confirm so its nonce is never shared. async function sendCanaryTx( @@ -221,12 +271,14 @@ async function handleCreate(admin: SupabaseClient, body: Record return { note_id: noteId, copy_index: index, ciphertext, iv }; }); - const chain = loadChain(); + const chain = loadChainOrNull(); + const solana = loadSolanaOrNull(); + requireProofChain(chain, solana); const chainNoteId = `0x${await sha256Hex(`nosus-canary:${noteId}`)}`; const { data: existing, error: readError } = await admin .from("canary_notes") - .select("owner_hash, seal_tx_hash, expires_at") + .select("owner_hash, seal_tx_hash, seal_solana_sig, expires_at") .eq("id", noteId) .maybeSingle(); if (readError) throw readError; @@ -234,9 +286,6 @@ async function handleCreate(admin: SupabaseClient, body: Record let expiresAt: string; if (existing) { if (existing.owner_hash !== ownerHash) throw new HttpError(409, "This note id is taken."); - if (existing.seal_tx_hash) { - return json({ seal_tx_hash: existing.seal_tx_hash, expires_at: existing.expires_at }); - } expiresAt = existing.expires_at; } else { expiresAt = new Date(Date.now() + hours * 3600 * 1000).toISOString(); @@ -256,26 +305,59 @@ async function handleCreate(admin: SupabaseClient, body: Record } } - const expiresAtSeconds = BigInt(Math.floor(new Date(expiresAt).getTime() / 1000)); - let sealTx: Hex; - try { - sealTx = await sendCanaryTx(admin, chain, "sealNote", [ - chainNoteId, - copyCount, - copiesHash, - expiresAtSeconds, - ]); - } catch (e) { + const expiresAtSeconds = Math.floor(new Date(expiresAt).getTime() / 1000); + const memo = sealMemo({ note: chainNoteId, copies: copyCount, hash: copiesHash, exp: expiresAtSeconds }); + const sealTxBefore: string | null = existing?.seal_tx_hash ?? null; + let sealSolana: string | null = existing?.seal_solana_sig ?? null; + const plan = planSeal({ + monadOn: chain !== null, + solanaOn: solana !== null, + sealTx: sealTxBefore, + sealSolana, + }); + + if (!chain || !plan.monad) { + // Nothing to send on Monad (Monad is off, or the note is sealed there + // already); at most the Solana memo is missing. + if (solana && plan.solana) { + const [sol] = await Promise.allSettled([sendSolanaMemo(solana, memo)]); + if (sol.status === "rejected" && plan.solanaRequired) { + // Solana is the only receipt: no memo means no seal. The row stays, and + // a retry with the same note id takes the existing-note path. + console.error("canary create: solana seal failed", sol.reason); + throw new HttpError(503, "Could not seal the note on Solana. Try again.", true); + } + sealSolana = solanaSig(sol, existing ? "canary create (retry)" : "canary create"); + if (sealSolana) { + await admin.from("canary_notes").update({ seal_solana_sig: sealSolana }).eq("id", noteId); + } + } + if (!isSealed({ seal_tx_hash: sealTxBefore, seal_solana_sig: sealSolana })) { + // Not reachable while requireProofChain holds; never hand out an unsealed link. + throw new HttpError(503, "Could not seal the note. Try again.", true); + } + return json({ seal_tx_hash: sealTxBefore, seal_solana_sig: sealSolana, expires_at: expiresAt }); + } + + // Monad and Solana in parallel. Monad is authoritative; Solana may fail quietly. + const [monad, sol] = await Promise.allSettled([ + sendCanaryTx(admin, chain, "sealNote", [chainNoteId, copyCount, copiesHash, BigInt(expiresAtSeconds)]), + solana && plan.solana ? sendSolanaMemo(solana, memo) : Promise.resolve(sealSolana), + ]); + sealSolana = solanaSig(sol, "canary create"); + if (monad.status === "rejected") { + const e = monad.reason; if (e instanceof HttpError) throw e; console.error("canary create: seal failed", e); throw new HttpError(503, "Could not seal the note on Monad. Try again.", true); } + const sealTx: Hex = monad.value; const { error: updateError } = await admin .from("canary_notes") - .update({ seal_tx_hash: sealTx }) + .update({ seal_tx_hash: sealTx, seal_solana_sig: sealSolana }) .eq("id", noteId); if (updateError) throw updateError; - return json({ seal_tx_hash: sealTx, expires_at: expiresAt }); + return json({ seal_tx_hash: sealTx, seal_solana_sig: sealSolana, expires_at: expiresAt }); } async function handleOpen(admin: SupabaseClient, body: Record) { @@ -292,15 +374,17 @@ async function handleOpen(admin: SupabaseClient, body: Record) const { data: note, error: noteError } = await admin .from("canary_notes") - .select("copy_count, chain_note_id, seal_tx_hash, expires_at") + .select("copy_count, chain_note_id, seal_tx_hash, seal_solana_sig, expires_at") .eq("id", noteId) .maybeSingle(); if (noteError) throw noteError; - if (!note || !note.seal_tx_hash) throw new HttpError(404, "This Canary link is not valid."); + if (!note || !isSealed(note)) throw new HttpError(404, "This Canary link is not valid."); if (new Date(note.expires_at) <= new Date()) throw new HttpError(410, "This Canary link has expired."); const tagSalt = requireEnv("CANARY_TAG_SALT"); - const chain = loadChain(); + const chain = loadChainOrNull(); + const solana = loadSolanaOrNull(); + requireProofChain(chain, solana); const deviceHash = await sha256Hex(`device:${deviceId}`); let assigned: Record | null = null; @@ -321,12 +405,28 @@ async function handleOpen(admin: SupabaseClient, body: Record) if (assigned === null) throw new HttpError(409, "Every copy of this note has been opened."); const copyIndex = Number(assigned.copy_index); + const readerTag = `0x${await sha256Hex(`${tagSalt}:${noteId}:${copyIndex}:${deviceHash}`)}`; + const memo = openMemo({ note: note.chain_note_id, copy: copyIndex, tag: readerTag }); let openTx = (assigned.open_tx_hash as string | null) ?? null; - if (!openTx) { - const readerTag = `0x${await sha256Hex(`${tagSalt}:${noteId}:${copyIndex}:${deviceHash}`)}`; - try { - openTx = await sendCanaryTx(admin, chain, "openCopy", [note.chain_note_id, copyIndex, readerTag]); - } catch (e) { + let openSolana = (assigned.open_solana_sig as string | null) ?? null; + const plan = planOpen({ + monadOn: chain !== null, + solanaOn: solana !== null, + noteSealTx: note.seal_tx_hash ?? null, + openTx, + openSolana, + }); + if (chain && plan.monad) { + // Monad and Solana in parallel. Monad is authoritative; Solana may fail quietly. + const [monad, sol] = await Promise.allSettled([ + sendCanaryTx(admin, chain, "openCopy", [note.chain_note_id, copyIndex, readerTag]), + solana && plan.solana ? sendSolanaMemo(solana, memo) : Promise.resolve(openSolana), + ]); + openSolana = solanaSig(sol, "canary open"); + if (monad.status === "fulfilled") { + openTx = monad.value; + } else { + const e = monad.reason; if (e instanceof HttpError) throw e; const message = e instanceof Error ? e.message : String(e); if (!message.includes("CopyTaken")) { @@ -338,9 +438,26 @@ async function handleOpen(admin: SupabaseClient, body: Record) } await admin .from("canary_copies") - .update({ reader_tag: readerTag, open_tx_hash: openTx }) + .update({ reader_tag: readerTag, open_tx_hash: openTx, open_solana_sig: openSolana }) .eq("note_id", noteId) .eq("copy_index", copyIndex); + } else if (solana && plan.solana) { + // Nothing to send on Monad (Monad is off, or this copy is recorded there + // already); only the Solana memo is missing. A failure stays null and is + // sent again on the next open of this copy. + const firstOpen = !assigned.reader_tag; + const [sol] = await Promise.allSettled([sendSolanaMemo(solana, memo)]); + openSolana = solanaSig(sol, firstOpen ? "canary open" : "canary open (retry)"); + const patch: Record = {}; + if (firstOpen) patch.reader_tag = readerTag; + if (openSolana) patch.open_solana_sig = openSolana; + if (Object.keys(patch).length > 0) { + await admin + .from("canary_copies") + .update(patch) + .eq("note_id", noteId) + .eq("copy_index", copyIndex); + } } return json({ @@ -349,6 +466,7 @@ async function handleOpen(admin: SupabaseClient, body: Record) ciphertext: assigned.ciphertext, iv: assigned.iv, open_tx_hash: openTx, + open_solana_sig: openSolana, opened_at: assigned.opened_at, }); } @@ -360,7 +478,7 @@ async function handleStatus(admin: SupabaseClient, body: Record const { data: note, error: noteError } = await admin .from("canary_notes") - .select("owner_hash, copy_count, seal_tx_hash, expires_at") + .select("owner_hash, copy_count, seal_tx_hash, seal_solana_sig, expires_at") .eq("id", noteId) .maybeSingle(); if (noteError) throw noteError; @@ -369,7 +487,7 @@ async function handleStatus(admin: SupabaseClient, body: Record const { data: copies, error: copiesError } = await admin .from("canary_copies") - .select("copy_index, reader_name, opened_at, open_tx_hash") + .select("copy_index, reader_name, opened_at, open_tx_hash, open_solana_sig") .eq("note_id", noteId) .not("opened_at", "is", null) .order("copy_index"); @@ -378,6 +496,7 @@ async function handleStatus(admin: SupabaseClient, body: Record return json({ copy_count: note.copy_count, seal_tx_hash: note.seal_tx_hash, + seal_solana_sig: note.seal_solana_sig, expires_at: note.expires_at, copies: copies ?? [], }); diff --git a/app/supabase/functions/canary/solana.ts b/app/supabase/functions/canary/solana.ts new file mode 100644 index 0000000..a7a23cd --- /dev/null +++ b/app/supabase/functions/canary/solana.ts @@ -0,0 +1,194 @@ +// Solana devnet proof for NO SUS Canary (the receipt; Monad is optional). +// +// Every seal and every open is written as one SPL Memo on Solana devnet. +// The memo carries the same sha256-based ids and hashes the Monad contract takes, and +// nothing else: never names, note text or keys. While +// SOLANA_RELAYER_KEYS is unset, loadSolana() returns null and nothing here runs +// (index.ts then needs Monad on, or create/open return 503). +// +// Unlike Monad, a Solana transaction is scoped by a recent blockhash, not an +// account nonce, so one relayer key can send concurrently and no lease is needed. +// +// Env (all optional here): +// SOLANA_RELAYER_KEYS comma-separated devnet keypairs, each either a base58 +// 64-byte secret key or a solana-keygen JSON array +// SOLANA_RPC_URL default https://api.devnet.solana.com +// SOLANA_MEMO_PROGRAM default MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr +// (SPL Memo: Solana Explorer shows the memo text). +// Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH (p-memo) +// is accepted too. + +import { + type Address, + address, + appendTransactionMessageInstruction, + createKeyPairSignerFromBytes, + createSolanaRpc, + createTransactionMessage, + getBase58Encoder, + getBase64EncodedWireTransaction, + getSignatureFromTransaction, + pipe, + setTransactionMessageFeePayerSigner, + setTransactionMessageLifetimeUsingBlockhash, + signTransactionMessageWithSigners, +} from "npm:@solana/kit@8.4.0"; +import { Buffer } from "node:buffer"; +import { + getAddMemoInstruction, + LEGACY_MEMO_PROGRAM_ADDRESS_V3, + SUPPORTED_MEMO_PROGRAM_ADDRESSES, +} from "npm:@solana-program/memo@0.15.0"; + +// The Supabase edge runtime resolves @solana/kit's Node build, whose base64 +// codec reads the global Buffer that Deno does not define. +(globalThis as { Buffer?: typeof Buffer }).Buffer ??= Buffer; + +export type SolanaConfig = { + rpcUrl: string; + /** 64-byte secret keys (private key || public key), as solana-keygen writes them. */ + keys: Uint8Array[]; + memoProgram: Address; +}; + +const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; +const BYTES32_RE = /^0x[0-9a-f]{64}$/; +const CONFIRM_TIMEOUT_MS = 20_000; +const CONFIRM_POLL_MS = 400; + +// Null when Solana is off. Throws on a bad config (the message never includes +// key material) so the caller can log it and carry on without Solana. +export function loadSolana(): SolanaConfig | null { + const raw = (Deno.env.get("SOLANA_RELAYER_KEYS") ?? "").trim(); + if (raw === "") return null; + + // Items are comma-separated, but a JSON array item contains commas itself. + const items = raw.match(/\[[^\]]*\]|[^,\[\]\s]+/g) ?? []; + const keys = items.map(parseSecretKey); + if (keys.length === 0 || keys.length > 32) { + throw new Error("SOLANA_RELAYER_KEYS must hold 1 to 32 keys"); + } + + const memoProgram = (Deno.env.get("SOLANA_MEMO_PROGRAM") ?? "").trim() || + LEGACY_MEMO_PROGRAM_ADDRESS_V3; + if (!(SUPPORTED_MEMO_PROGRAM_ADDRESSES as readonly string[]).includes(memoProgram)) { + throw new Error("SOLANA_MEMO_PROGRAM is not a known Memo program address"); + } + + return { + rpcUrl: Deno.env.get("SOLANA_RPC_URL") ?? "https://api.devnet.solana.com", + keys, + memoProgram: address(memoProgram), + }; +} + +function parseSecretKey(item: string): Uint8Array { + let bytes: Uint8Array; + if (item.startsWith("[")) { + let parsed: unknown; + try { + parsed = JSON.parse(item); + } catch { + throw new Error("SOLANA_RELAYER_KEYS: a JSON key is not valid JSON"); + } + if ( + !Array.isArray(parsed) || + !parsed.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) + ) { + throw new Error("SOLANA_RELAYER_KEYS: a JSON key must be an array of bytes"); + } + bytes = Uint8Array.from(parsed as number[]); + } else { + if (!BASE58_RE.test(item)) { + throw new Error("SOLANA_RELAYER_KEYS: a key must be base58 or a JSON array"); + } + bytes = new Uint8Array(getBase58Encoder().encode(item)); + } + if (bytes.length !== 64) throw new Error("SOLANA_RELAYER_KEYS: a key must be 64 bytes"); + return bytes; +} + +function bytes32(name: string, value: string): string { + if (!BYTES32_RE.test(value)) throw new Error(`memo ${name} must be 0x + 64 hex chars`); + return value; +} + +function uint(name: string, value: number): number { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`memo ${name} must be a non-negative integer`); + } + return value; +} + +// {"p":"nosus-canary","v":1,"t":"seal","note":"0x…","copies":N,"hash":"0x…","exp":E} +// exp is unix seconds, 0 when the note never expires. +export function sealMemo(p: { note: string; copies: number; hash: string; exp: number }): string { + return JSON.stringify({ + p: "nosus-canary", + v: 1, + t: "seal", + note: bytes32("note", p.note), + copies: uint("copies", p.copies), + hash: bytes32("hash", p.hash), + exp: uint("exp", p.exp), + }); +} + +// {"p":"nosus-canary","v":1,"t":"open","note":"0x…","copy":N,"tag":"0x…"} +export function openMemo(p: { note: string; copy: number; tag: string }): string { + return JSON.stringify({ + p: "nosus-canary", + v: 1, + t: "open", + note: bytes32("note", p.note), + copy: uint("copy", p.copy), + tag: bytes32("tag", p.tag), + }); +} + +// Sends one memo instruction from a random relayer key and waits until the +// transaction is confirmed. Returns the base58 signature. Polls over HTTP; no +// WebSocket subscriptions. +export async function sendSolanaMemo(cfg: SolanaConfig, memo: string): Promise { + const rpc = createSolanaRpc(cfg.rpcUrl); + const signer = await createKeyPairSignerFromBytes( + cfg.keys[Math.floor(Math.random() * cfg.keys.length)], + ); + const { value: latest } = await rpc.getLatestBlockhash({ commitment: "confirmed" }).send(); + + const message = pipe( + createTransactionMessage({ version: 0 }), + (m) => setTransactionMessageFeePayerSigner(signer, m), + (m) => setTransactionMessageLifetimeUsingBlockhash(latest, m), + (m) => + appendTransactionMessageInstruction( + getAddMemoInstruction({ memo, signers: [signer] }, { programAddress: cfg.memoProgram }), + m, + ), + ); + const transaction = await signTransactionMessageWithSigners(message); + const signature = getSignatureFromTransaction(transaction); + + await rpc + .sendTransaction(getBase64EncodedWireTransaction(transaction), { + encoding: "base64", + preflightCommitment: "confirmed", + }) + .send(); + + const deadline = Date.now() + CONFIRM_TIMEOUT_MS; + for (;;) { + const { value: [status] } = await rpc.getSignatureStatuses([signature]).send(); + if (status?.err) { + const err = JSON.stringify(status.err, (_, v) => (typeof v === "bigint" ? v.toString() : v)); + throw new Error(`solana memo ${signature} failed: ${err}`); + } + if (status?.confirmationStatus === "confirmed" || status?.confirmationStatus === "finalized") { + return signature; + } + if (Date.now() >= deadline) { + throw new Error(`solana memo ${signature} not confirmed within ${CONFIRM_TIMEOUT_MS} ms`); + } + await new Promise((resolve) => setTimeout(resolve, CONFIRM_POLL_MS)); + } +} diff --git a/app/supabase/migrations/20261010090000_nosus_canary_solana.sql b/app/supabase/migrations/20261010090000_nosus_canary_solana.sql new file mode 100644 index 0000000..ff0fd82 --- /dev/null +++ b/app/supabase/migrations/20261010090000_nosus_canary_solana.sql @@ -0,0 +1,80 @@ +-- NO SUS Canary: Solana devnet proof. Experiment project ffidvfguojalzpclipzi ONLY. +-- Never apply to production (rxfnazmusofikwaggntb). +-- +-- Adds the Solana memo signature next to each Monad transaction hash. Like the +-- Monad hashes these are public identifiers of sha256-based ids; the server +-- still never stores note text, keys, salts or codewords. +-- +-- Apply before deploying the `canary` edge function that selects these columns. +-- +-- Reverse (manual, if ever needed): +-- drop function if exists public.canary_assign_copy(uuid, text, text); +-- alter table public.canary_copies drop column if exists open_solana_sig; +-- alter table public.canary_notes drop column if exists seal_solana_sig; +-- -- then re-create canary_assign_copy and its grants from 20260919090000_nosus_canary.sql + +alter table public.canary_notes + add column if not exists seal_solana_sig text + check (seal_solana_sig is null or seal_solana_sig ~ '^[1-9A-HJ-NP-Za-km-z]{64,100}$'); + +alter table public.canary_copies + add column if not exists open_solana_sig text + check (open_solana_sig is null or open_solana_sig ~ '^[1-9A-HJ-NP-Za-km-z]{64,100}$'); + +-- Same function as before plus open_solana_sig in the result. Postgres cannot +-- change a function's result columns with "or replace", so drop it first; the +-- grants are dropped with it and re-applied below. +drop function if exists public.canary_assign_copy(uuid, text, text); + +-- Give a device its copy: the same copy again if it already has one, +-- otherwise the lowest unassigned copy. Concurrency-safe (SKIP LOCKED + the +-- one-per-device unique index). Returns no row when every copy is taken. +create or replace function public.canary_assign_copy( + p_note_id uuid, + p_device_hash text, + p_reader_name text +) +returns table ( + copy_index integer, + ciphertext text, + iv text, + reader_tag text, + opened_at timestamptz, + open_tx_hash text, + open_solana_sig text +) +language plpgsql +security definer +set search_path = public +as $$ +#variable_conflict use_column +begin + return query + select c.copy_index, c.ciphertext, c.iv, c.reader_tag, c.opened_at, c.open_tx_hash, c.open_solana_sig + from public.canary_copies c + where c.note_id = p_note_id + and c.reader_device_hash = p_device_hash; + if found then + return; + end if; + + return query + update public.canary_copies c + set reader_device_hash = p_device_hash, + reader_name = p_reader_name, + opened_at = now() + where (c.note_id, c.copy_index) = ( + select c2.note_id, c2.copy_index + from public.canary_copies c2 + where c2.note_id = p_note_id + and c2.reader_device_hash is null + order by c2.copy_index + limit 1 + for update skip locked + ) + returning c.copy_index, c.ciphertext, c.iv, c.reader_tag, c.opened_at, c.open_tx_hash, c.open_solana_sig; +end; +$$; + +revoke all on function public.canary_assign_copy(uuid, text, text) from public, anon, authenticated; +grant execute on function public.canary_assign_copy(uuid, text, text) to service_role; diff --git a/app/test/unit/canary_link_test.dart b/app/test/unit/canary_link_test.dart index 6abcc31..a16e62f 100644 --- a/app/test/unit/canary_link_test.dart +++ b/app/test/unit/canary_link_test.dart @@ -66,4 +66,31 @@ void main() { '0xb8168efcc5c17afc454283c0e2237b39f5dc917d39ab0eb0f540f544b9878f2e', ); }); + + test('explorer links point at the right testnet per chain', () { + final monadHash = '0x${'ab' * 32}'; + // Base58 Solana signatures are 86–88 chars; this one is 88. + final solanaSig = '5' * 88; + + expect( + canaryExplorerTxUrl(monadHash), + 'https://testnet.monadscan.com/tx/$monadHash', + ); + expect( + canarySolanaExplorerTxUrl(solanaSig), + 'https://explorer.solana.com/tx/$solanaSig?cluster=devnet', + ); + expect(CanaryChain.monad.txUrl(monadHash), canaryExplorerTxUrl(monadHash)); + expect( + CanaryChain.solana.txUrl(solanaSig), + canarySolanaExplorerTxUrl(solanaSig), + ); + }); + + test('chain labels read correctly in chips and sentences', () { + expect(CanaryChain.monad.label, 'Monad'); + expect(CanaryChain.solana.label, 'Solana'); + expect(CanaryChain.monad.network, 'Monad testnet'); + expect(CanaryChain.solana.network, 'Solana devnet'); + }); } diff --git a/app/test/unit/canary_models_test.dart b/app/test/unit/canary_models_test.dart new file mode 100644 index 0000000..fc74d38 --- /dev/null +++ b/app/test/unit/canary_models_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/features/canary/domain/canary_fingerprint.dart'; +import 'package:no_sus/features/canary/domain/canary_models.dart'; + +const _noteId = '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed'; +final _monadHash = '0x${'ab' * 32}'; +final _solanaSig = '5' * 88; + +CanaryOwnerRecord _record({String? sealTxHash, String? sealSolanaSig}) => + CanaryOwnerRecord( + noteId: _noteId, + keyHex: 'cd' * 32, + ownerSecret: 'ef' * 32, + saltHex: '01' * 32, + copyCount: 2, + createdAt: DateTime.utc(2026, 10, 10, 12), + expiresAt: DateTime.utc(2026, 10, 11, 12), + readerLink: 'https://x.y/#/canary/$_noteId?k=${'cd' * 32}', + preview: 'The quick brown fox jumps over the lazy dog.', + plan: buildCanaryPlan( + 'The quick brown fox jumps over the lazy dog. ' + 'It was a very big and really nice day for a walk.', + ), + codewords: const [ + [0, 1, 0], + [1, 0, 1], + ], + sealTxHash: sealTxHash, + sealSolanaSig: sealSolanaSig, + ); + +void main() { + test('owner record round-trips with both proofs', () { + final original = _record( + sealTxHash: _monadHash, + sealSolanaSig: _solanaSig, + ); + final json = original.toJson(); + expect(json['v'], 1); + expect(json['sealTxHash'], _monadHash); + expect(json['sealSolanaSig'], _solanaSig); + + final restored = CanaryOwnerRecord.fromJson(json); + expect(restored.noteId, original.noteId); + expect(restored.sealTxHash, _monadHash); + expect(restored.sealSolanaSig, _solanaSig); + expect(restored.codewords, original.codewords); + expect(restored.isSealed, isTrue); + }); + + test('owner record round-trips without a Solana proof', () { + final original = _record(sealTxHash: _monadHash); + final json = original.toJson(); + expect(json.containsKey('sealSolanaSig'), isTrue); + expect(json['sealSolanaSig'], isNull); + + final restored = CanaryOwnerRecord.fromJson(json); + expect(restored.sealTxHash, _monadHash); + expect(restored.sealSolanaSig, isNull); + // A Monad seal alone still counts, so records from the Monad build + // keep working. + expect(restored.isSealed, isTrue); + }); + + test('records saved before Solana proofs load with a null signature', () { + final legacy = _record(sealTxHash: _monadHash).toJson() + ..remove('sealSolanaSig'); + expect(legacy.containsKey('sealSolanaSig'), isFalse); + + final restored = CanaryOwnerRecord.fromJson(legacy); + expect(restored.sealTxHash, _monadHash); + expect(restored.sealSolanaSig, isNull); + expect(restored.isSealed, isTrue); + }); + + test('copyWith adds the Solana proof without dropping the Monad one', () { + final draft = _record(); + expect(draft.isSealed, isFalse); + + final sealed = draft.copyWith( + sealTxHash: _monadHash, + sealSolanaSig: _solanaSig, + ); + expect(sealed.sealTxHash, _monadHash); + expect(sealed.sealSolanaSig, _solanaSig); + + final untouched = sealed.copyWith(expiresAt: DateTime.utc(2027)); + expect(untouched.sealTxHash, _monadHash); + expect(untouched.sealSolanaSig, _solanaSig); + }); + + test('note status settles on Monad alone', () { + final future = DateTime.now().add(const Duration(days: 1)); + final status = CanaryNoteStatus( + copyCount: 1, + sealTxHash: _monadHash, + sealSolanaSig: null, + expiresAt: future, + copies: [ + CanaryCopyStatus( + copyIndex: 0, + readerName: 'Sam', + openedAt: DateTime.now(), + openTxHash: _monadHash, + openSolanaSig: null, + ), + ], + ); + expect(status.isSettled, isTrue); + expect(status.copy(0)?.openSolanaSig, isNull); + }); + + test('a Solana-only seal counts as sealed (Monad off)', () { + final record = _record(sealSolanaSig: _solanaSig); + expect(record.sealTxHash, isNull); + expect(record.isSealed, isTrue); + + final restored = CanaryOwnerRecord.fromJson(record.toJson()); + expect(restored.sealTxHash, isNull); + expect(restored.sealSolanaSig, _solanaSig); + expect(restored.isSealed, isTrue); + }); + + CanaryCopyStatus copy({String? tx, String? sig}) => CanaryCopyStatus( + copyIndex: 0, + readerName: 'Sam', + openedAt: DateTime.now(), + openTxHash: tx, + openSolanaSig: sig, + ); + + CanaryNoteStatus status(CanaryCopyStatus c) => CanaryNoteStatus( + copyCount: 1, + sealTxHash: null, + sealSolanaSig: _solanaSig, + expiresAt: DateTime.now().add(const Duration(days: 1)), + copies: [c], + ); + + test('a copy is recorded by either chain', () { + expect(copy().isRecorded, isFalse); + expect(copy(tx: _monadHash).isRecorded, isTrue); + expect(copy(sig: _solanaSig).isRecorded, isTrue); + }); + + test('note status settles on Solana alone (Monad off)', () { + expect(status(copy(sig: _solanaSig)).isSettled, isTrue); + // The open memo has not landed yet: keep polling. + expect(status(copy()).isSettled, isFalse); + }); +} diff --git a/app/test/widget/canary_app_test.dart b/app/test/widget/canary_app_test.dart index e45bb7d..f8f1591 100644 --- a/app/test/widget/canary_app_test.dart +++ b/app/test/widget/canary_app_test.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/config/app_mode.dart'; import 'package:no_sus/core/providers/theme_provider.dart'; import 'package:no_sus/features/canary/presentation/canary_app.dart'; +import 'package:no_sus/features/canary/presentation/ui/wallet_button.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Widget tests draw text in a placeholder font whose glyphs are full @@ -56,6 +58,19 @@ void main() { expect(find.text('Every reader gets their own copy.'), findsOneWidget); expect(find.byType(BackButton), findsNothing); + // CANARY_MONAD decides whether any Monad UI exists at all. + final monadText = find.textContaining( + RegExp(r'[Mm]onad|MONAD|\bMON\b'), + ); + if (kCanaryMonad) { + expect(find.byType(WalletButton), findsOneWidget); + expect(monadText, findsWidgets); + } else { + expect(find.byType(WalletButton), findsNothing); + expect(monadText, findsNothing); + expect(find.textContaining('SOLANA'), findsWidgets); + } + await tester.pumpWidget(const SizedBox()); }); } diff --git a/app/test/widget/proof_chips_test.dart b/app/test/widget/proof_chips_test.dart new file mode 100644 index 0000000..e833165 --- /dev/null +++ b/app/test/widget/proof_chips_test.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_sus/config/app_mode.dart'; +import 'package:no_sus/features/canary/presentation/ui/chain_chip.dart'; +import 'package:no_sus/features/canary/presentation/ui/proof_chips.dart'; +import 'package:no_sus/theme.dart'; + +/// Widget tests draw text in a placeholder font whose glyphs are full +/// squares, which makes the pixel-font labels measure about twice their real +/// width. Load the real bundled fonts so layout checks match the browser. +Future _loadFonts() async { + const families = { + 'Outfit': ['Outfit-Regular', 'Outfit-SemiBold', 'Outfit-Bold'], + 'Inter': ['Inter-Regular', 'Inter-Medium', 'Inter-SemiBold', 'Inter-Bold'], + 'VT323': ['VT323-Regular'], + }; + for (final entry in families.entries) { + final loader = FontLoader(entry.key); + for (final file in entry.value) { + loader.addFont(rootBundle.load('assets/google_fonts/$file.ttf')); + } + await loader.load(); + } +} + +const _monadTx = + '0x9f2c4b1e7d3a5c6b8e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d'; +const _solanaSig = + '5VfYmGZ8s3kQxq2wE1rT9yU4iO6pA7sD8fG0hJ1kL2zX3cV4bN5mQm2p'; + +Widget _host(Widget child, {double? width}) => MaterialApp( + theme: CanaryTokens.theme(), + home: Scaffold( + body: Center( + child: width == null ? child : SizedBox(width: width, child: child), + ), + ), +); + +void main() { + setUpAll(_loadFonts); + + testWidgets('Monad only shows one linked chip and nothing for Solana', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadTxHash: _monadTx, + labelPrefix: 'Sealed on', + showMonad: true, + ), + ), + ); + + expect(find.byType(ChainChip), findsOneWidget); + expect(find.text('Sealed on Monad · 0x9f2c…1c2d'), findsOneWidget); + expect(find.textContaining('Solana'), findsNothing); + expect(find.byType(PendingChainChip), findsNothing); + expect( + find.bySemanticsLabel(RegExp(r'View proof on Monad testnet')), + findsOneWidget, + ); + }); + + testWidgets('Monad + Solana shows both chips with bounded hashes', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadTxHash: _monadTx, + solanaSig: _solanaSig, + labelPrefix: 'Recorded on', + showMonad: true, + ), + ), + ); + + expect(find.byType(ChainChip), findsNWidgets(2)); + expect(find.text('Recorded on Monad · 0x9f2c…1c2d'), findsOneWidget); + // Base58 signature: no 0x prefix, first 4 + last 4. + expect(find.text('Recorded on Solana · 5VfY…Qm2p'), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp(r'View proof on Solana devnet')), + findsOneWidget, + ); + }); + + testWidgets('pending Solana shows a progress ring and no link', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadTxHash: _monadTx, + solanaPending: true, + showMonad: true, + ), + ), + ); + // One frame only: the progress ring animates forever. + await tester.pump(); + + expect(find.byType(ChainChip), findsOneWidget); + expect(find.byType(PendingChainChip), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Solana…'), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp(r'Recording on Solana devnet')), + findsOneWidget, + ); + expect(find.bySemanticsLabel(RegExp(r'View proof on Solana')), findsNothing); + expect(find.byType(InkWell), findsOneWidget); // only the Monad chip taps + }); + + testWidgets('Solana off (null, not pending) renders nothing for Solana', ( + tester, + ) async { + await tester.pumpWidget( + _host(const ProofChips(monadTxHash: _monadTx, showMonad: true)), + ); + + expect(find.byType(ChainChip), findsOneWidget); + expect(find.byType(PendingChainChip), findsNothing); + expect(find.textContaining('Solana'), findsNothing); + }); + + testWidgets('two chips wrap onto a second row at 320 px without overflow', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadTxHash: _monadTx, + solanaSig: _solanaSig, + labelPrefix: 'Recorded on', + showMonad: true, + ), + width: 320, + ), + ); + + expect(tester.takeException(), isNull); + final chips = find.byType(ChainChip); + expect(chips, findsNWidgets(2)); + final first = tester.getRect(chips.at(0)); + final second = tester.getRect(chips.at(1)); + expect(second.top, greaterThanOrEqualTo(first.bottom)); + expect(first.height, greaterThanOrEqualTo(48)); + expect(second.height, greaterThanOrEqualTo(48)); + expect(first.right, lessThanOrEqualTo(tester.getRect(find.byType(ProofChips)).right)); + }); + + group('Monad hidden (CANARY_MONAD off)', () { + testWidgets('only the Solana chip shows, even when a Monad hash exists', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadTxHash: _monadTx, + solanaSig: _solanaSig, + monadPending: true, + labelPrefix: 'Sealed on', + showMonad: false, + ), + ), + ); + + expect(find.byType(ChainChip), findsOneWidget); + expect(find.text('Sealed on Solana · 5VfY…Qm2p'), findsOneWidget); + expect(find.byType(PendingChainChip), findsNothing); + expect(find.textContaining('Monad'), findsNothing); + expect(find.bySemanticsLabel(RegExp('Monad')), findsNothing); + }); + + testWidgets('a Solana-only proof renders on its own', (tester) async { + await tester.pumpWidget( + _host(const ProofChips(solanaSig: _solanaSig, showMonad: false)), + ); + + expect(find.byType(ChainChip), findsOneWidget); + expect(find.text('Solana · 5VfY…Qm2p'), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp(r'View proof on Solana devnet')), + findsOneWidget, + ); + }); + + testWidgets('pending Solana alone shows only the progress chip', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ProofChips( + monadPending: true, + solanaPending: true, + showMonad: false, + ), + ), + ); + await tester.pump(); + + expect(find.byType(ChainChip), findsNothing); + expect(find.byType(PendingChainChip), findsOneWidget); + expect(find.text('Solana…'), findsOneWidget); + expect(find.textContaining('Monad'), findsNothing); + }); + + testWidgets('nothing to show renders an empty box', (tester) async { + await tester.pumpWidget( + _host(const ProofChips(monadTxHash: _monadTx, showMonad: false)), + ); + + expect(find.byType(ChainChip), findsNothing); + expect(find.byType(PendingChainChip), findsNothing); + }); + }); + + testWidgets('showMonad defaults to the CANARY_MONAD build flag', ( + tester, + ) async { + await tester.pumpWidget(_host(const ProofChips(monadTxHash: _monadTx))); + + expect( + find.byType(ChainChip), + kCanaryMonad ? findsOneWidget : findsNothing, + ); + }); +} diff --git a/assets/readme/banner.svg b/assets/readme/banner.svg index f439c6b..ce610ae 100644 --- a/assets/readme/banner.svg +++ b/assets/readme/banner.svg @@ -15,5 +15,5 @@ - + diff --git a/assets/readme/story.svg b/assets/readme/story.svg index 6e49b87..29bc3bd 100644 --- a/assets/readme/story.svg +++ b/assets/readme/story.svg @@ -1,5 +1,5 @@ - -Animation: the sender seals a note on Monad, five readers each receive a different copy, Priya's copy leaks, NO SUS scans it and the canary sings: copy 03 is Priya's. + +Animation: the sender seals a note on Solana, five readers each receive a different copy, Priya's copy leaks, NO SUS scans it and the canary sings: copy 03 is Priya's.

@@ -552,7 +564,7 @@ function readme() { ## Step 1: Watch -

Animation: the sender seals a note on Monad, five readers each receive a different copy, Priya's copy leaks, NO SUS scans it and the canary sings: copy 03 is Priya's.

+

Animation: the sender seals a note on Solana, five readers each receive a different copy, Priya's copy leaks, NO SUS scans it and the canary sings: copy 03 is Priya's.

## Step 2: Try it @@ -564,11 +576,12 @@ function readme() {

Battle text: A wild LEAK appeared! Canary used FINGERPRINT! It's super effective! It was Priya's copy, number 03.

-## Step 3: Why Monad +## Step 3: Why Solana -- **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. +- **One memo per seal and per open.** Sealing a note and every **Open my copy** each write one Memo-program transaction on Solana devnet. +- **Hashes only.** A memo carries a hashed note id, one hash of all copies and an anonymous reader tag. Never the text, the key or a name. +- **A link on every chip.** Each record has its own Solana Explorer link in the app. +- **No wallet for readers.** A relayer pays the fee, about **0.000005 SOL** per memo. ## Judge checklist @@ -576,6 +589,8 @@ function readme() { |---|---|---| ${checklist} +${monadLine} + > **Honest limits:** it names *whose copy* leaked, not *who shared it*. It doesn't stop screenshots. It makes them traceable. ## More @@ -584,8 +599,8 @@ ${checklist} How the fingerprint works - **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. +- **Seal it.** Copies are encrypted in your browser. The key lives only in the link's \`#fragment\`. A seal memo puts one hash of all copies on Solana devnet before anyone reads. +- **Share it.** Each reader gets the next unused copy. An open memo records it on Solana 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. @@ -603,13 +618,13 @@ cd app flutter pub get flutter run -d chrome -# Contract -cd ../contracts -npm ci -npx hardhat test +# Edge function checks (offline) +cd .. +npx -y deno@2 check app/supabase/functions/canary/index.ts +npx -y deno@2 test app/supabase/functions/canary/gating_test.ts \`\`\` -Own backend and contract: see [\`docs/canary/NOSUS_CANARY_BUILD_PLAN.md\`](docs/canary/NOSUS_CANARY_BUILD_PLAN.md). +Own backend: see [\`docs/canary/NOSUS_CANARY_BUILD_PLAN.md\`](docs/canary/NOSUS_CANARY_BUILD_PLAN.md).
@@ -619,9 +634,9 @@ Own backend and contract: see [\`docs/canary/NOSUS_CANARY_BUILD_PLAN.md\`](docs/ %%{init: {'theme':'base','themeVariables':{'primaryColor':'${PAPER}','primaryTextColor':'${INK}','primaryBorderColor':'${INK}','lineColor':'${ORANGE}','fontFamily':'monospace'}}}%% flowchart LR S["Sender app"] -- "encrypted copies" --> F["canary function"] - F -- "sealNote" --> M[("NoSusCanary on Monad")] + F -- "seal memo" --> M[("Memo program on Solana devnet")] R["Reader"] -- "open + name" --> F - F -- "openCopy" --> M + F -- "open memo" --> M F -- "their copy" --> R \`\`\` @@ -643,11 +658,11 @@ Fingerprint test harness, 15-slot demo note: | Partial excerpts (30–70%) | named only when sure | 0 / 600 |
-Full technical write-up (setup, deployment, contract and relayers): [\`docs/TECHNICAL_README.md\`](docs/TECHNICAL_README.md) +Full technical write-up (setup, deployment and relayers): [\`docs/TECHNICAL_README.md\`](docs/TECHNICAL_README.md) --- -${team ? `**Team:** ${team} · ` : ''}Built at **${cfg.eventName}** · part of **NO SUS** +${team ? `**Team:** ${team} · ` : ''}Part of **NO SUS**

Game over? Play again at the live app.

`; diff --git a/tool/solana-spike/.gitignore b/tool/solana-spike/.gitignore new file mode 100644 index 0000000..8535fbc --- /dev/null +++ b/tool/solana-spike/.gitignore @@ -0,0 +1,2 @@ +# Devnet relayer keypair written by send_memo.ts. Never commit. +.keys/ diff --git a/tool/solana-spike/README.md b/tool/solana-spike/README.md new file mode 100644 index 0000000..da6c07e --- /dev/null +++ b/tool/solana-spike/README.md @@ -0,0 +1,39 @@ +# Solana devnet memo spike + +Sends one NO SUS Canary "seal" memo and one "open" memo to Solana devnet through +the production module `app/supabase/functions/canary/solana.ts` (the exact code +path the `canary` edge function uses), then reads one transaction back and +checks that the decoded memo equals what was sent. Memos carry dummy +sha256-shaped ids only; no note text, names or keys. + +Deno is not installed in this repo; `npx` fetches the official npm build: + +```sh +npx -y deno run -A tool/solana-spike/send_memo.ts +``` + +## Keys + +On the first run the script generates a devnet keypair and writes it to +`tool/solana-spike/.keys/relayer.json` in `solana-keygen` format (a JSON array +of 64 bytes). The directory is git-ignored and the secret is never printed; +only the public key is. Later runs reuse the same file. + +The script feeds that file to `loadSolana()` through `SOLANA_RELAYER_KEYS`, so it +also proves the JSON-array form of the secret parses. `SOLANA_RPC_URL` is +honoured if set (default `https://api.devnet.solana.com`). + +## Funding + +Devnet SOL is free. The script asks the RPC faucet for 1 SOL when the balance is +below 0.01 SOL and retries a few times with backoff. The public faucet is +rate-limited; if it keeps refusing, fund the printed public key by hand at + and run the script again. One memo costs 5000 +lamports (0.000005 SOL), so 1 SOL covers about 200,000 memos. + +## Using the key as the edge function relayer + +Owner only, experiment project only, with explicit approval (see `AGENTS.md`): +set `SOLANA_RELAYER_KEYS` to the contents of `relayer.json` (or to a base58 +64-byte secret key; several keys may be comma-separated). Leaving it unset keeps +the Solana proof off and the function behaves exactly as before. diff --git a/tool/solana-spike/send_memo.ts b/tool/solana-spike/send_memo.ts new file mode 100644 index 0000000..13b1e57 --- /dev/null +++ b/tool/solana-spike/send_memo.ts @@ -0,0 +1,164 @@ +// NO SUS Canary: Solana devnet memo spike. +// +// Exercises the exact production code path (app/supabase/functions/canary/ +// solana.ts): loads or generates a devnet relayer key, funds it by airdrop, +// sends one seal memo and one open memo, then reads one back and checks the +// text. The secret key is written to .keys/relayer.json and never printed. +// +// npx -y deno run -A tool/solana-spike/send_memo.ts + +import { + createKeyPairSignerFromBytes, + createKeyPairSignerFromPrivateKeyBytes, + createSolanaRpc, + getBase58Encoder, + lamports, + type Signature, +} from "npm:@solana/kit@8.4.0"; +import { + loadSolana, + openMemo, + sealMemo, + sendSolanaMemo, +} from "../../app/supabase/functions/canary/solana.ts"; + +const KEYS_DIR = new URL(".keys/", import.meta.url); +const KEY_FILE = new URL("relayer.json", KEYS_DIR); +const MIN_BALANCE = 10_000_000n; // 0.01 SOL +const AIRDROP = lamports(1_000_000_000n); // 1 SOL + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function hex64(): string { + return Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function explorer(sig: string, rpcUrl: string): string { + const cluster = rpcUrl.includes("testnet") ? "testnet" : "devnet"; + return `https://explorer.solana.com/tx/${sig}?cluster=${cluster}`; +} + +// Returns the solana-keygen style JSON array (64 bytes) for the relayer, +// generating and saving a new one when the file does not exist yet. +async function loadOrCreateKeyJson(): Promise { + try { + return (await Deno.readTextFile(KEY_FILE)).trim(); + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e; + } + const seed = crypto.getRandomValues(new Uint8Array(32)); + const signer = await createKeyPairSignerFromPrivateKeyBytes(seed); + const secret = new Uint8Array(64); + secret.set(seed, 0); + secret.set(getBase58Encoder().encode(signer.address), 32); + const json = JSON.stringify(Array.from(secret)); + await Deno.mkdir(KEYS_DIR, { recursive: true }); + await Deno.writeTextFile(KEY_FILE, json + "\n"); + console.log("Generated a new devnet keypair in tool/solana-spike/.keys/relayer.json"); + return json; +} + +type Rpc = ReturnType; + +async function waitConfirmed(rpc: Rpc, sig: Signature, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const { value: [status] } = await rpc.getSignatureStatuses([sig]).send(); + if (status?.err) return false; + if (status?.confirmationStatus === "confirmed" || status?.confirmationStatus === "finalized") { + return true; + } + await sleep(500); + } + return false; +} + +async function main() { + Deno.env.set("SOLANA_RELAYER_KEYS", await loadOrCreateKeyJson()); + const cfg = loadSolana(); + if (!cfg) throw new Error("loadSolana() returned null although SOLANA_RELAYER_KEYS is set"); + const signer = await createKeyPairSignerFromBytes(cfg.keys[0]); + console.log(`Relayer public key: ${signer.address}`); + console.log(`RPC: ${cfg.rpcUrl}`); + console.log(`Memo program: ${cfg.memoProgram}`); + + const rpc = createSolanaRpc(cfg.rpcUrl); + const balance = async () => + (await rpc.getBalance(signer.address, { commitment: "confirmed" }).send()).value; + console.log(`Balance: ${Number(await balance()) / 1e9} SOL`); + + if ((await balance()) < MIN_BALANCE) { + let funded = false; + for (let attempt = 1; attempt <= 4 && !funded; attempt++) { + try { + const sig = await rpc.requestAirdrop(signer.address, AIRDROP, { commitment: "confirmed" }) + .send(); + console.log(`Airdrop requested (${attempt}/4): ${sig}`); + funded = await waitConfirmed(rpc, sig, 30_000); + } catch (e) { + console.log(`Airdrop attempt ${attempt}/4 failed: ${e instanceof Error ? e.message : String(e)}`); + } + if (!funded && attempt < 4) await sleep(2_000 * 2 ** (attempt - 1)); + } + if (!funded) { + console.error( + `Devnet airdrop is rate-limited. Fund ${signer.address} at https://faucet.solana.com and run again.`, + ); + Deno.exit(1); + } + console.log(`Balance after airdrop: ${Number(await balance()) / 1e9} SOL`); + } + + const note = `0x${hex64()}`; + const seal = sealMemo({ + note, + copies: 3, + hash: `0x${hex64()}`, + exp: Math.floor(Date.now() / 1000) + 3600, + }); + console.log(`\nSeal memo (${new TextEncoder().encode(seal).length} bytes): ${seal}`); + let t0 = Date.now(); + const sealSig = await sendSolanaMemo(cfg, seal); + console.log(` confirmed in ${Date.now() - t0} ms: ${sealSig}`); + console.log(` ${explorer(sealSig, cfg.rpcUrl)}`); + + const open = openMemo({ note, copy: 0, tag: `0x${hex64()}` }); + console.log(`\nOpen memo (${new TextEncoder().encode(open).length} bytes): ${open}`); + t0 = Date.now(); + const openSig = await sendSolanaMemo(cfg, open); + console.log(` confirmed in ${Date.now() - t0} ms: ${openSig}`); + console.log(` ${explorer(openSig, cfg.rpcUrl)}`); + + // Round trip: read the seal transaction back and compare the memo text. + const tx = await rpc + .getTransaction(sealSig as Signature, { + encoding: "jsonParsed", + maxSupportedTransactionVersion: 0, + commitment: "confirmed", + }) + .send(); + if (!tx) throw new Error("getTransaction returned null"); + const ix = tx.transaction.message.instructions[0] as unknown as { + program?: string; + parsed?: unknown; + data?: unknown; + }; + // SPL Memo (MemoSq4…) is parsed by the RPC into a plain string; p-memo is not, + // so fall back to decoding the raw base58 instruction data. + const decoded = typeof ix.parsed === "string" + ? ix.parsed + : new TextDecoder().decode(getBase58Encoder().encode(String(ix.data ?? ""))); + console.log(`\nRead back (${ix.program ?? "raw"}): ${decoded}`); + console.log(`Compute units: ${tx.meta?.computeUnitsConsumed ?? "?"}, fee: ${tx.meta?.fee ?? "?"} lamports`); + if (decoded !== seal) { + console.error("MISMATCH: decoded memo differs from the sent memo"); + Deno.exit(1); + } + console.log("Round trip OK: decoded memo equals the sent memo."); + console.log(`Balance: ${Number(await balance()) / 1e9} SOL`); +} + +await main(); From 7f02505de4e3acaf4e4d7264522ac72e9673eaa4 Mon Sep 17 00:00:00 2001 From: https-shubhamsahu Date: Sat, 10 Oct 2026 17:35:31 +0530 Subject: [PATCH 2/2] docs: Canary app now served at /nosus-canary/ (app/ redirects) Co-Authored-By: Claude Opus 5.5 --- docs/STATUS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index f4eeb27..7be9b5f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -33,7 +33,7 @@ 3. `supabase secrets set --project-ref ffidvfguojalzpclipzi SOLANA_RELAYER_KEYS=… SOLANA_RPC_URL=https://api.devnet.solana.com` (read the key from a PowerShell variable; set `SOLANA_MEMO_PROGRAM` only if the default must change). 4. Apply the migration: `supabase db query --linked --file app/supabase/migrations/20261010090000_nosus_canary_solana.sql`. **Never `supabase db push`**: build-plan rule 5 records that the remote migration history holds only the bootstrap entry, so `db push` would try to re-apply ~21 old migrations and break the database. 5. `supabase functions deploy canary --project-ref ffidvfguojalzpclipzi --no-verify-jwt --use-api` (same flags as the build plan; without `--no-verify-jwt` the function returns 401). - 6. Rebuild the web app: from `app/`, in PowerShell, `flutter build web --release --base-href /nosus-canary/app/ --dart-define=NOSUS_CANARY_ONLY=true` (Monad hidden by default; add `--dart-define=CANARY_MONAD=true` only to reproduce the Monad build); copy `build/web/.` into `app/` on `gh-pages`, then commit and push (see the 22 Sep hosting note below). + 6. Rebuild the web app: from `app/`, in PowerShell, `flutter build web --release --base-href /nosus-canary/ --dart-define=NOSUS_CANARY_ONLY=true` (Monad hidden by default; add `--dart-define=CANARY_MONAD=true` only to reproduce the Monad build); copy `build/web/.` into the root of `gh-pages` (keep `hub/` and the `app/` redirect), then commit and push (see the 22 Sep hosting note below). 7. Verify: create a Canary link, open a copy in a private window, and click the **Solana** chip. It must open `https://explorer.solana.com/tx/?cluster=devnet` showing one Memo instruction whose JSON starts `{"p":"nosus-canary","v":1,"t":"open"` and contains hashes only. Record the seal signature in "Evidence so far" and in `docs/canary/VERIFICATION.md`. ## NO SUS Canary — 19 Sep 2026 @@ -53,7 +53,7 @@ - `docs/canary/reference/` was removed after being merged. The fingerprint harness is now `app/tool/canary_fingerprint_harness.dart` (FAILURES: 0). - Checks: 133 passed, 1 skipped; analyze 0 issues. - Hosting (22 Sep 2026): this repo, renamed from `nosus-monad` (old URLs redirect), now also hosts the site. Source is on `main`; the built site is on `gh-pages` and served at http://shubham-sahu.me/nosus-canary/ (`app/` = the Flutter build, `hub/` + `index.html` = landing). - - To deploy: from `app/`, run `flutter build web --release --base-href /nosus-canary/app/ --dart-define=NOSUS_CANARY_ONLY=true` in PowerShell, copy `build/web/.` into `app/` on `gh-pages`, then commit and push. + - To deploy: from `app/`, run `flutter build web --release --base-href /nosus-canary/ --dart-define=NOSUS_CANARY_ONLY=true` in PowerShell, copy `build/web/.` into the root of `gh-pages` (keep `hub/` and the `app/` redirect), then commit and push. Since 10 Oct 2026 the app is served at https://shubham-sahu.me/nosus-canary/; `/app/` redirects there keeping the hash, so older reader links still open. - The separate site repo was retired. ## Current state