Harden unwrapGift for the receive side: verify the seal's BIP-340 signature and recomputed id, enforce that the rumor's author matches the seal signer (NIP-59 anti-spoofing), and check the rumor id against its contents. The rumor is unsigned by design, so sender authentication rests on the seal. Add UnwrapDirectMessage to NostrDmActor, returning the authenticated sender, content, timestamp, and tags. Cover both with tests: an unwrap round-trip and a rejection of a wrap whose inner rumor spoofs a different author. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| .dart_tool | ||
| lib | ||
| test | ||
| .gitignore | ||
| analysis_options.yaml | ||
| devbox.json | ||
| devbox.lock | ||
| GUIDELINES.md | ||
| pubspec.lock | ||
| pubspec.yaml | ||
| README.md | ||
swarm
Actor-based business logic library. Each actor runs in an isolated Dart isolate; you send it a message and await a result. Actors are created with a factory tear-off so the handler is constructed inside the isolate:
final actor = Actor.create(MyActor.new);
final result = await actor.send(MyMessage());
await actor.stop();
All examples below call handle() directly (no isolate), which is how the
unit tests work. In production code use Actor.create + send.
FileActor
Local file I/O — text and binary.
final actor = FileActor();
No constructor parameters.
| Message | Result | Description |
|---|---|---|
LoadFile(path) |
FileLoaded(content) |
Read file as UTF-8 string |
SaveFile(path, content) |
FileSaved() |
Write string to file |
LoadBinaryFile(path) |
BinaryFileLoaded(bytes) |
Read file as Uint8List |
SaveBinaryFile(path, bytes) |
FileSaved() (same type) |
Write Uint8List to file |
final actor = FileActor();
// Text
await actor.handle(SaveFile('/tmp/note.txt', 'hello'));
final r = await actor.handle(LoadFile('/tmp/note.txt'));
print((r as FileLoaded).content); // hello
// Binary
await actor.handle(SaveBinaryFile('/tmp/data.bin', bytes));
final rb = await actor.handle(LoadBinaryFile('/tmp/data.bin'));
final loaded = (rb as BinaryFileLoaded).bytes;
NostrSignerActor
Signs Nostr events (NIP-01) using a secp256k1 private key.
final actor = NostrSignerActor(privateKeyHex);
| Parameter | Type | Description |
|---|---|---|
privateKey |
String |
64-char hex private key |
| Message | Result | Description |
|---|---|---|
SignEvent(kind, content, tags?, createdAt?) |
EventSigned(event) |
Returns a signed event as a JSON string with all seven NIP-01 fields |
GetPublicKey() |
PublicKeyResult(publicKey) |
Returns the hex public key for this signer |
tags defaults to []. createdAt defaults to DateTime.now() (Unix seconds).
final signer = NostrSignerActor(privkeyHex);
final pk = await signer.handle(const GetPublicKey());
print((pk as PublicKeyResult).publicKey);
final signed = await signer.handle(
const SignEvent(kind: 1, content: 'hello world'),
);
final eventJson = (signed as EventSigned).event; // ready to publish
Nip46SignerActor
NIP-46 remote signer — drop-in replacement for NostrSignerActor. Delegates
signing to an external signer (e.g. Nsec.app) over a Nostr
relay using NIP-44 encrypted kind-24133 events. Accepts a bunker:// URI.
final actor = Nip46SignerActor(
bunkerUri: 'bunker://<signerPubkey>?relay=wss://relay.nsec.app&secret=<secret>',
);
| Parameter | Type | Default | Description |
|---|---|---|---|
bunkerUri |
String |
required | Bunker URI from the remote signer app |
appPrivkey |
String? |
random | App's ephemeral private key (injectable for tests) |
connectionFactory |
RelayConnection Function(String)? |
WebSocketRelayConnection.new |
Override relay factory for tests |
timeout |
Duration |
30 s | Per-request timeout |
Implements the same SignerMessage/SignerResult protocol as NostrSignerActor:
| Message | Result | Description |
|---|---|---|
GetPublicKey() |
PublicKeyResult(publicKey) |
Returns the signer's public key (cached after first call) |
SignEvent(kind, content, tags?, createdAt?) |
EventSigned(event) |
Sends a sign_event request to the remote signer; returns the fully signed event JSON |
Call actor.close() when done to tear down the relay connection.
final signer = Nip46SignerActor(
bunkerUri: bunkerUri, // scanned from QR or pasted by user
);
final pk = await signer.handle(const GetPublicKey());
print((pk as PublicKeyResult).publicKey);
final signed = await signer.handle(
const SignEvent(kind: 1, content: 'hello from mobile'),
);
final eventJson = (signed as EventSigned).event;
await signer.close();
The actor automatically sends a connect handshake on first use and reuses the
relay connection for all subsequent requests.
NostrPublishActor
Publishes signed Nostr events to one or more relays and collects OK/rejection outcomes. Retries failed relay connections with exponential back-off.
final actor = NostrPublishActor(
relayUrls: ['wss://relay.example.com'],
retryConfig: const RetryConfig(), // optional
batchTimeout: const Duration(seconds: 10), // optional
);
| Parameter | Type | Default | Description |
|---|---|---|---|
relayUrls |
List<String> |
required | Relay WebSocket URLs |
retryConfig |
RetryConfig |
see below | Retry behaviour |
batchTimeout |
Duration |
10 s | How long to wait for OK per relay |
RetryConfig
| Field | Default | Description |
|---|---|---|
maxAttempts |
3 | Max connection attempts per relay |
baseDelay |
1 s | Initial retry delay |
maxDelay |
32 s | Cap on retry delay |
jitter |
true |
Add ±25 % randomisation to delay |
| Message | Result | Description |
|---|---|---|
PublishBatch(events) |
BatchPublished(outcomes) |
Publishes a list of JSON event strings; outcomes maps relay URL → RelayConfirmed, RelayRejected(reason), or RelayFailed |
Events must be fully signed JSON strings (e.g. from NostrSignerActor).
final signer = NostrSignerActor(privkeyHex);
final publisher = NostrPublishActor(
relayUrls: ['wss://relay.otherwhere.app/'],
);
final signed = await signer.handle(
const SignEvent(kind: 1, content: 'hello'),
);
final result = await publisher.handle(
PublishBatch([(signed as EventSigned).event]),
);
final outcomes = (result as BatchPublished).outcomes;
for (final entry in outcomes.entries) {
print('${entry.key}: ${entry.value}');
// wss://relay.otherwhere.app/: RelayConfirmed
}
NostrFetchActor
Fetches Nostr events from one or more relays using a NIP-01 filter. Collects events until EOSE or a deadline, deduplicates by event ID.
final actor = NostrFetchActor(
relayUrls: ['wss://relay.example.com'],
eoseDeadline: const Duration(seconds: 5), // optional
);
| Parameter | Type | Default | Description |
|---|---|---|---|
relayUrls |
List<String> |
required | Relay WebSocket URLs |
eoseDeadline |
Duration |
5 s | Max wait per relay for EOSE |
| Message | Result | Description |
|---|---|---|
FetchEvents(filter) |
EventsFetched(events) |
filter is a JSON string (NIP-01 filter object); events is a deduplicated list of JSON event strings |
final fetcher = NostrFetchActor(
relayUrls: ['wss://relay.otherwhere.app/'],
);
final result = await fetcher.handle(
FetchEvents('{"kinds":[1],"authors":["$pubkeyHex"],"limit":10}'),
);
final events = (result as EventsFetched).events;
for (final e in events) {
print(jsonDecode(e)['content']);
}
GeoActor
Geohash encoding, decoding, and neighbour lookup via the dart_geohash library.
final actor = GeoActor();
No constructor parameters.
| Message | Result | Description |
|---|---|---|
EncodeLocation(latitude, longitude, precision?) |
GeohashEncoded(geohash) |
Encode lat/lon to geohash string; precision defaults to 9 |
DecodeGeohash(geohash) |
LocationDecoded(latitude, longitude) |
Decode geohash to coordinates |
NeighborsOf(geohash) |
NeighborsResult(neighbors) |
Returns a Map<String, String> with keys NORTH, NORTHEAST, EAST, SOUTHEAST, SOUTH, SOUTHWEST, WEST, NORTHWEST, CENTRAL |
final geo = GeoActor();
final enc = await geo.handle(
const EncodeLocation(latitude: 48.8566, longitude: 2.3522, precision: 7),
);
print((enc as GeohashEncoded).geohash); // u09tunq
final dec = await geo.handle(DecodeGeohash('u09tunq'));
print((dec as LocationDecoded).latitude); // ~48.856
print((dec as LocationDecoded).longitude); // ~2.352
final nb = await geo.handle(NeighborsOf('u09tunq'));
print((nb as NeighborsResult).neighbors['NORTH']); // u09tunu
FileDownloadActor
Downloads a file from a URL and saves it to disk via an injected FileActor.
final actor = FileDownloadActor(
fileActor: FileActor(),
);
| Parameter | Type | Default | Description |
|---|---|---|---|
fileActor |
FileActor |
required | Actor used to write the downloaded bytes to disk |
httpClient |
http.Client? |
new client | Injectable HTTP client; provide a MockClient in tests |
Call actor.close() when done to release the HTTP client.
| Message | Result | Description |
|---|---|---|
DownloadFile(url, savePath) |
FileDownloaded(savePath) |
GETs the URL and writes the response bytes to savePath. Throws if the response status is not 2xx. |
final actor = FileDownloadActor(fileActor: FileActor());
final result = await actor.handle(
const DownloadFile(
url: 'https://example.com/image.jpg',
savePath: '/tmp/image.jpg',
),
);
print((result as FileDownloaded).savePath); // /tmp/image.jpg
await actor.close();
FileUploadActor
Uploads files to a Nostr-authenticated remote storage service. Handles NIP-44 encrypted registration (kind 5392) and NIP-98 signed upload requests (kind 27235). Returns a permanent public CDN URL.
final actor = FileUploadActor(
privateKey: userPrivkeyHex,
appName: kAppName,
appSecret: kAppSecret,
);
| Parameter | Type | Default | Description |
|---|---|---|---|
privateKey |
String |
required | User's 64-char hex Nostr private key |
appName |
String |
required | App identifier registered with the service |
appSecret |
String |
required | Per-app shared secret (keep out of public source) |
apiBaseUrl |
String |
https://upload.otherwhere.app |
API base URL |
relayUrl |
String |
wss://relay.otherwhere.app/ |
Nostr relay for registration events |
Call actor.close() when done to release HTTP connections.
| Message | Result | Description |
|---|---|---|
RegisterForUpload() |
UploaderRegistered() |
Publishes a kind 5392 event to the relay with the NIP-44 encrypted app secret. Call once per user; re-registering is safe (idempotent server-side). |
UploadFile(bytes, contentType) |
FileUploaded(cdnUrl) |
Requests a presigned PUT URL, uploads the file directly to storage, and returns the permanent CDN URL. Throws if bytes.length > 10 MB. Accepted content types: image/jpeg, image/png, image/webp, image/gif. |
Registration should be called once and the result persisted (e.g. in
flutter_secure_storage). If the flag is lost, calling it again is harmless.
Do not block the upload flow on registration — unregistered users get a small
daily upload allowance while the registration event propagates.
const storage = FlutterSecureStorage();
const _kRegisteredKey = 'upload_registered_$kAppName';
final actor = FileUploadActor(
privateKey: await storage.read(key: 'nostr_privkey'),
appName: kAppName,
appSecret: kAppSecret,
);
// Register once
if (await storage.read(key: _kRegisteredKey) != 'true') {
await actor.handle(const RegisterForUpload());
await storage.write(key: _kRegisteredKey, value: 'true');
}
// Upload
final result = await actor.handle(
UploadFile(bytes: imageBytes, contentType: 'image/jpeg'),
);
final url = (result as FileUploaded).cdnUrl;
// https://cdn.otherwhere.app/<app>/<pubkey>/<uuid>.jpg
NostrAccountActor
Manages a Nostr user identity: keypair lifecycle, BIP-39 mnemonic support (NIP-06 derivation), and kind-0 profile fetch/publish via injected helper actors.
final actor = NostrAccountActor(
fetcher: NostrFetchActor(relayUrls: ['wss://relay.example.com']),
publisher: NostrPublishActor(relayUrls: ['wss://relay.example.com']),
);
| Parameter | Type | Default | Description |
|---|---|---|---|
fetcher |
NostrFetchActor? |
null | Required for FetchProfile |
publisher |
NostrPublishActor? |
null | Required for UpdateProfile |
Both helpers are optional at construction; passing neither is valid when only using key management messages.
| Message | Result | Description |
|---|---|---|
CreateAccount() |
AccountCreated(publicKeyHex, mnemonic) |
Generates a new keypair from 128-bit entropy via BIP-39 + NIP-06 derivation |
ImportAccount(privateKeyHex) |
AccountImported(publicKeyHex) |
Loads an existing raw hex private key. ExportMnemonic returns null afterwards. |
ImportMnemonic(mnemonic) |
AccountImported(publicKeyHex) |
Derives keypair from a 12-word BIP-39 mnemonic via NIP-06 path m/44'/1237'/0'/0/0 |
GetAccountPublicKey() |
AccountPublicKeyResult(publicKeyHex) |
Returns the loaded account's public key |
ExportPrivateKey() |
PrivateKeyExported(privateKeyHex) |
Returns the raw private key |
ExportMnemonic() |
MnemonicExported(mnemonic?) |
Returns the BIP-39 mnemonic, or null if the account was imported via raw private key |
FetchProfile() |
ProfileFetched(profile?) |
Fetches kind-0 from relay; caches result for GetPublicData |
UpdateProfile(profile) |
ProfileUpdated() |
Publishes a kind-0 event; caches profile for GetPublicData |
GetPublicData() |
PublicData(data) |
Returns a Map<String, dynamic> with pubkey plus any cached profile fields |
NostrProfile fields (all optional): name, displayName, about, picture,
banner, website, nip05, lud16.
All messages except Create/Import throw StateError if called before the account is
loaded. FetchProfile throws if no fetcher was provided; UpdateProfile throws if
no publisher was provided.
final fetcher = NostrFetchActor(relayUrls: ['wss://relay.otherwhere.app/']);
final publisher = NostrPublishActor(relayUrls: ['wss://relay.otherwhere.app/']);
final actor = NostrAccountActor(fetcher: fetcher, publisher: publisher);
// New user
final created = await actor.handle(const CreateAccount()) as AccountCreated;
print(created.mnemonic); // 12-word backup phrase
print(created.publicKeyHex); // hex pubkey
// Returning user — restore from mnemonic
await actor.handle(ImportMnemonic(savedMnemonic));
// Or restore from raw key
await actor.handle(ImportAccount(savedPrivkeyHex));
// Update profile
await actor.handle(
UpdateProfile(const NostrProfile(name: 'alice', nip05: 'alice@example.com')),
);
// Fetch latest profile from relay
final fetched = await actor.handle(const FetchProfile()) as ProfileFetched;
print(fetched.profile?.name);
// Public-facing data (pubkey + cached profile)
final pub = await actor.handle(const GetPublicData()) as PublicData;
print(pub.data); // {'pubkey': '...', 'name': 'alice', 'nip05': 'alice@example.com'}