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();