Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 21 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

## Step 1: Watch

<p align="center"><img src="assets/readme/story.svg" width="100%" alt="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."></p>
<p align="center"><img src="assets/readme/story.svg" width="100%" alt="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."></p>

## Step 2: Try it

Expand All @@ -26,25 +26,26 @@

<p align="center"><img src="assets/readme/battle.svg" width="100%" alt="Battle text: A wild LEAK appeared! Canary used FINGERPRINT! It's super effective! It was Priya's copy, number 03."></p>

## 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
Expand All @@ -53,8 +54,8 @@
<summary><b>How the fingerprint works</b></summary>

- **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.
</details>

Expand All @@ -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).
</details>

<details>
Expand All @@ -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
```

Expand All @@ -112,10 +113,10 @@ Fingerprint test harness, 15-slot demo note:
| Partial excerpts (30–70%) | named only when sure | 0 / 600 |
</details>

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

<p align="center"><a href="http://shubham-sahu.me/nosus-canary/"><img src="assets/readme/game-over.svg" width="100%" alt="Game over? Play again at the live app."></a></p>
9 changes: 9 additions & 0 deletions app/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 12 additions & 2 deletions app/lib/config/app_mode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
);
23 changes: 20 additions & 3 deletions app/lib/features/canary/data/canary_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
);
}
Expand All @@ -103,6 +116,7 @@ class CanaryApi {
String ciphertext,
String iv,
String? openTxHash,
String? openSolanaSig,
DateTime openedAt,
})
>
Expand All @@ -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),
);
}
Expand All @@ -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)
Expand All @@ -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?,
),
],
);
Expand Down
2 changes: 1 addition & 1 deletion app/lib/features/canary/data/canary_crypto.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> copyDigests) =>
'0x${sha256Hex(copyDigests.join())}';
Expand Down
11 changes: 9 additions & 2 deletions app/lib/features/canary/data/canary_repository.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<CanaryOwnerRecord> createNote({
required String text,
required int copyCount,
Expand Down Expand Up @@ -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,
Expand All @@ -130,6 +135,7 @@ class CanaryRepository {
);
final sealed = draft.copyWith(
sealTxHash: result.sealTxHash,
sealSolanaSig: result.sealSolanaSig,
expiresAt: result.expiresAt,
);
await _store.saveNote(sealed);
Expand Down Expand Up @@ -177,6 +183,7 @@ class CanaryRepository {
readerName: name,
openedAt: result.openedAt,
openTxHash: result.openTxHash,
openSolanaSig: result.openSolanaSig,
);
}

Expand Down
25 changes: 24 additions & 1 deletion app/lib/features/canary/domain/canary_link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,34 @@ 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'))}';

/// 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);
}
Loading
Loading