-## 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**
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 @@
-