Skip to content

ricochet-dart-client

Skill ricochet-dart-client. Use when writing Dart or Flutter code against package:ricochet (ricochet-dart-client) to talk to a Ricochet server: SFClient setup on an existing dart_libp2p host, sendMessage/retrieveMessages, flags and deletes, push notifications, NaCl box encryption with PayloadEncryptor, mailboxes and ACLs, documents, feeds, collections, claim mailboxes.

SKILL.md (Claude Code, Codex, Cursor) · ricochet-dart-client.md (Boubou) · install: curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- ricochet-dart-client

package:ricochet is the Dart client for Ricochet store-and-forward servers (go-ricochet). It opens libp2p streams on the Ricochet protocol IDs over a dart_libp2p host that the application has already built and connected; it never creates or manages networking itself.

Host construction (transports, Noise, yamux, relay, hole punching) is in the dart-libp2p-host skill; UDX is in udx-dart. The server side is ricochet-server; the end-to-end offline-messaging pattern is ricochet-mailbox.

dependencies:
ricochet: ^0.3.1 # the README still says ^0.2.1; 0.3.1 is current

It accepts dart_libp2p >=1.0.0 <5.0.0, dart_libp2p_pubsub >=1.1.0 <5.0.0 and dart_libp2p_kad_dht >=1.2.0 <2.0.0; use dart_libp2p 4.1+ for working hole punching behind NAT.

import 'dart:convert'; // utf8
import 'dart:typed_data'; // Uint8List
import 'package:ricochet/ricochet.dart';
import 'package:dart_libp2p/core/peer/peer_id.dart';
import 'package:dart_libp2p/core/peer/addr_info.dart';
import 'package:dart_libp2p/core/multiaddr.dart';
import 'package:dart_libp2p/core/network/context.dart' as core_context;
// host: a started dart_libp2p Host (UDX + Noise + yamux), see dart-libp2p-host.
final serverId = PeerId.fromString('12D3KooW...');
final serverAddr = MultiAddr('/ip4/203.0.113.10/udp/55223/udx');
await host.peerStore.addrBook.addAddr(serverId, serverAddr, const Duration(hours: 24));
await host.connect(AddrInfo(serverId, [serverAddr]), context: core_context.Context());
final client = SFClient(
host: host,
config: SFClientConfig(
preferredServers: [SFServerPreference(serverId: serverId, priority: 10)],
connectionTimeout: const Duration(seconds: 20),
messageTimeout: const Duration(seconds: 20),
),
pubsub: pubsub, // optional: presence, shared/public pushes
encryptor: PayloadEncryptor.fromEd25519Seed(seed), // optional: E2E payloads
);
client.registerServerAddress(serverId, serverAddr); // lets it re-add the address after TTL expiry
await client.start(); // registers the push handler
final res = await client.sendMessage(
recipient: bobId,
payload: Uint8List.fromList(utf8.encode('hello')),
folderPath: 'inbox', // default 'inbox'
priority: MessagePriority.normal,
expiry: const Duration(days: 7),
persistent: false, // removed when the recipient acknowledges it
encrypt: true, // needs an encryptor
);
if (!res.success) print('not stored: ${res.errorMessage}');
final msgs = await client.retrieveMessages(throwOnFailure: true);
for (final m in msgs) {
if (m.flags.isEncrypted) continue; // did not open; see Gotchas
handle(m.senderPeerId, m.payload);
}
await client.markMessagesDelivered(msgs.map((m) => m.messageId).toList());

Paging: retrieveMessages(maxMessages: 100, fromSequence: last.sequenceNumber! + 1). hasMore is not exposed on SFClient; keep paging until a page comes back short.

await client.updateMessageFlags(id, addFlags: MessageFlags.flagged);
await client.updateMessageFlags(id, addFlags: MessageFlags.deleted);
await client.expungeMessages(); // or folderPath: 'orders'
await client.deleteMessages([id]); // immediate
client.notifications.listen((n) async {
// n.mailboxPath is the folder; n.ownerPeerId whose mailbox; n.serverPeerId who sent it
final from = n.serverPeerId != null ? PeerId.fromString(n.serverPeerId!) : null;
await client.retrieveMessages(fromServer: from, folderPath: n.mailboxPath);
});
await client.subscribeToMailbox(ownerId.toBase58(), 'announcements'); // shared/public, needs pubsub

Private mailboxes are pushed over /ricochet/mailbox-notify/1.0.0 on the connection the client opened; shared and public ones over GossipSub topic getMailboxTopic(owner, folder) = /ricochet/mailbox/<owner>/<folder>.

final mb = client.mailboxes;
await mb.createMailbox(folderPath: 'team', type: MailboxType.shared, maxMessages: 500, retentionDays: 7);
await mb.grantAccess(folderPath: 'team', type: MailboxType.shared,
targetPeerId: aliceId, accessMode: AccessMode.readWrite);
final info = await mb.getMailboxInfo(folderPath: 'team', type: MailboxType.shared);
final all = await mb.listMailboxes();
await mb.deleteMailbox(folderPath: 'team', type: MailboxType.shared);
await owner.mailboxes.createMailbox(folderPath: 'prekeys', type: MailboxType.claim);
// owner then sendMessage()s items to itself with folderPath: 'prekeys'
final item = await other.claimMessage(ownerPeerId: ownerId, folderPath: 'prekeys'); // null when none left
final put = await client.putDocument(
path: 'profile',
content: Uint8List.fromList(utf8.encode('{"name":"Ann"}')),
visibility: DocumentVisibility.public, // new documents are private by default
history: 5, // keep 5 earlier versions
);
if (put == null || !put.isSuccess) throw StateError('put failed: ${put?.status}');
final doc = await client.getDocument(ownerPeerId: annId, path: 'profile', ifNoneMatch: put.etag);
if (doc != null && doc.isNotModified) { /* 304 */ }
await client.patchDocument(path: 'profile', patch: {'bio': 'hi'}, ifMatch: put.etag);
final meta = await client.headDocument(ownerPeerId: annId, path: 'profile');
final list = await client.listDocuments(ownerPeerId: annId, pathPrefix: 'settings/');
final versions = await client.getDocumentHistory(ownerPeerId: annId, path: 'profile');
await client.restoreDocumentVersion(path: 'profile', versionNumber: versions!.first.versionNumber);
await client.setDocumentVisibility(path: 'profile', visibility: DocumentVisibility.shared);
final deleted = await client.deleteDocument(path: 'profile');
await client.createFeed(path: 'posts', title: 'Posts', collaborative: false);
final a = await client.appendFeedEntry(path: 'posts', content: bytes, entryType: 'post');
final page = await client.getFeedEntries(ownerPeerId: annId, path: 'posts', fromSequence: 1, limit: 50);
await client.createCollection(path: 'contacts', name: 'Contacts', visibility: CollectionVisibility.private);
await client.putCollectionItem(path: 'contacts', key: 'bob', content: Uint8List.fromList(utf8.encode('{"city":"Paris"}')));
final q = await client.queryCollection(ownerPeerId: annId, path: 'contacts',
filter: {'city': 'Paris'}, sortField: 'city', sortAsc: true, limit: 20);

Directory: joinDirectory(displayName:, bio:, extras:), browseDirectory(search:, limit:, cursor:), leaveDirectory().

  • SFClient - entry point; every call takes an optional fromServer / toServer / serverPeerId to pin one server.
  • SFClientConfig - preferredServers, enableAutoRetrieval (polls every retrievalInterval, default 1 min), maxRetries (3), connectionTimeout (10 s), messageTimeout (30 s), retry delays.
  • SFServerPreference(serverId:, priority:, weight: 50) - lower priority wins.
  • ServerSelector - priority order, marks an unreachable server unavailable for 30 s.
  • SendResult - success, messageId, storedAtServer, errorMessage.
  • SFMessage - messageId, senderPeerId, recipientPeerId, payload, flags (SFMessageFlags), priority, folderPath, sequenceNumber, createdTimestamp, expiryTimestamp.
  • MessageFlags - seen, flagged, deleted, draft (int bit masks).
  • MailboxType - private, shared, public, claim; AccessMode - readOnly, writeOnly, readWrite.
  • MailboxManager (client.mailboxes) - create, update, delete, grant, revoke, listACL, getMailboxInfo, listMailboxes; throws MailboxManagementError.
  • PayloadEncryptor - fromEd25519Seed, fromLibp2pKey; wire format "RCE2" || nonce(24) || box(binding || payload), identical to Go.
  • DocumentVisibility, CollectionVisibility - private, shared, public.
  • Presence (needs pubsub): client.trackContactPresence(contact, serverId), isContactOnline, getContactPresence, contactPresenceChanges (null without pubsub). The README’s client.presenceTracker getter does not exist.
  • RicochetUri / RicochetUriResolver - ricochet://<owner>/<type>/<path>?servers=....
  • Exceptions: RetrieveFailedException, RetrieveRefusedException (status 403/404/400), PayloadDecryptException, BoundToAnotherMessageException, MailboxManagementError.
  • The README’s flag API is out of date. The methods are markMessagesDelivered, updateMessageFlags, expungeMessages (positional ids), and getDocument / listDocuments require ownerPeerId. Trust the signatures in lib/client/sf_client.dart.
  • Most calls swallow errors and return null or false. Timeouts, no server and transport faults come back as null (getDocument, putDocument, flag acks, deletes) or false (deleteDocument, deleteFeed). retrieveMessages returns [] unless you pass throwOnFailure: true. Always pass it where an empty inbox and an unreachable server must be told apart.
  • A non-null store response can still be a failure. putDocument, getDocument, setDocumentVisibility return the server’s status: check for 403 (not yours / not readable), 404, 409 (ETag mismatch), 507 (quota).
  • deleteDocument returns false for “absent” and for “refused” alike. Follow it with headDocument if you must know which.
  • SendResult has no status code. A full mailbox or a missing write grant arrives only as errorMessage text (mailbox full, no write access to shared mailbox). Do not retry a full mailbox on a timer; only the owner can drain it.
  • A send that times out is retried up to maxRetries with backoff, so a submission the server stored but could not acknowledge can arrive twice. De-duplicate on the receiving side.
  • Retrieval is not consumption. Messages stay on the server until markMessagesDelivered (deletes non-persistent, sets \Seen on persistent), deleteMessages, \Deleted + expunge, retention or expiry. A client that only keeps a local cursor fills the mailbox to its cap (1000) and every sender then fails.
  • A message that will not open is returned sealed, not dropped. Its flags.isEncrypted stays true and payload is ciphertext. Delete it or hand it to your own E2E layer; never treat it as plaintext.
  • The encryptor must be built from the host’s own identity seed. The recipient derives the sender’s X25519 key from the sender’s peer ID; an encryptor from another key produces messages nobody can open. fromLibp2pKey only works for keys that still expose their seed (built with Ed25519PrivateKey.fromRawBytes); otherwise it throws StateError.
  • Without an encryptor, sendMessage(encrypt: true) throws StateError.
  • Encryption is not forward secret and the envelope (sender, recipient, folder, time, size) is visible to the server. Folder bindings are UTF-8 since 0.3.0: messages sealed by 0.2.x to a non-ASCII folder no longer open.
  • MailboxManager uses the first server in preferredServers list order, not the priority order ServerSelector uses, and its requests have no timeout. List servers in priority order.
  • Admin calls are keyed by type. getMailboxInfo(folderPath, type) with the wrong type is “not found”. Probe shared then private if unsure. listMailboxes() entries have no message count; getMailboxInfo does.
  • The first writer decides a folder’s type for life. A delivery to a folder that does not exist creates it private (open to any sender). Create shared or claim mailboxes, and grant writers, before anyone sends.
  • start() registers the push handler; stop() cannot be undone. Stop closes the incoming-message stream, after which a non-empty retrieval fails internally and returns []. Build a new SFClient instead of restarting.
  • Notifications only arrive while connected. The server pushes over the client’s own connection, so behind a NAT that drops idle UDP mappings you must reconnect and retrieve on reconnect. The client protects the server connection in the connection manager after a successful call.
  • claimMessage returns null both when empty and once the hourly share (30 per mailbox) is used. Status 400 means a server older than 1.6.0. Do not give a claim mailbox a retentionCount.
  • createFeed on SFClient has no visibility parameter (feeds default public); use FeedHandler on a raw stream for visibility. queryCollection wantTotal and cursor are on CollectionHandler only.
  • setPreferredServers does nothing but log; recreate the client to change servers.
  • Two test files that each start a server time out side by side; keep dart_test.yaml concurrency: 1.

Integration tests start a real go-ricochet binary and a throwaway database (test/support/ricochet_server.dart, RicochetTestServer.start()), and build hosts with test/support/test_host.dart (testHost(keyPair): UDX, Noise, yamux, maxStreams: 256).

Terminal window
(cd ../go-ricochet && go build -o ricochet ./cmd/ricochet)
export RICOCHET_TEST_PG=postgresql://user:pass@localhost:5433/postgres
dart test
dart run bin/store_interop_test.dart # store interop checks against go-ricochet

Tests skip with a reason when the binary or PostgreSQL is missing (RicochetTestServer.available()). Give connectionTimeout and messageTimeout 20 s in tests; UDX dials on CI are slow. Unit-test frame code without a server: AccessFrame, CollectionFrame and the encryption vectors in test/payload_encryption_test.dart, which match Go’s TestCrossLanguageVectors byte for byte.

  • Never change the payload encryption format or binding encoding without updating the Go vectors too; messages stop opening across languages.
  • Never log or persist the Ed25519 seed passed to PayloadEncryptor.
  • Never treat a null / [] result as “nothing there” in sync logic; use throwOnFailure or check status.
  • Never leave a stream open on a custom handler call; yamux allows 256 per connection and the client relies on closing every one.
  • Never delete and recreate a mailbox to change its type.

Built by Stephan Februarygithub.com/stephanfebLinkedIn