15 KiB
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 |
Nip44Encrypt(plaintext) |
Nip44Text(value) |
NIP-44 v2 encrypt-to-self: seals plaintext with this signer's own key (base64 payload). Useful for kind-30078 app data. |
Nip44Decrypt(payload) |
Nip44Text(value) |
Inverse of Nip44Encrypt. Throws on a version mismatch or HMAC failure. |
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 |
The Nip44Encrypt/Nip44Decrypt messages are not supported by the remote
signer yet — only GetPublicKey and SignEvent are proxied over NIP-46.
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'}