feat(mobile): AsyncStorage-backed offline tx outbox with auto-replay (#450) - #519
feat(mobile): AsyncStorage-backed offline tx outbox with auto-replay (#450)#519northvictor wants to merge 3 commits into
Conversation
Port sdk/src/outbox.ts to frontend/mobile/lib/outbox.ts with: - AsyncStorage persistence so queued transactions survive app restarts - NetInfo listener for automatic replay when connectivity returns - Hash dedup + sequence-number uniqueness for safe at-most-once replay - useAutoReplayOutbox hook for reactive connectivity monitoring Refs: Miracle656#450, backlog item 22
|
@northvictor is attempting to deploy a commit to the miracle656's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
@northvictor Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Miracle656
left a comment
There was a problem hiding this comment.
This is the tidiest PR in the current queue — two files, no route collisions, and it merges into main with zero conflicts. I ran it:
$ git merge origin/main # 0 conflicts
$ npm install && npm run typecheck
TYPECHECK_EXIT=0
The structure is good: per-entry error isolation so one bad envelope can't abort the batch, a mutations map so persistence happens once rather than per iteration, and a real distinction between failed (drop) and stillPending (retry) instead of collapsing both into an error. Recording the envelope before sending is the right ordering.
Two defects though, and the second one is serious for this specific component.
1. Sequence-number uniqueness is documented but not implemented
The module doc claims:
* is prevented by transaction-hash dedup and sequence-number uniqueness.But enqueue only dedups on hash:
const entries = await this.list();
const existing = entries.find((e) => e.hash === input.hash);
if (existing) return existing;sequence is never checked for uniqueness — it's only used to sort (.sort((a, b) => BigInt(a.sequence) < BigInt(b.sequence) ? -1 : 1)).
Stellar requires strictly increasing sequence numbers per source account, so two distinct transactions queued at the same sequence can never both succeed — on replay, whichever goes second is rejected. Right now nothing stops them both being queued. Either enforce it:
const clash = entries.find(
(e) => e.sequence === String(input.sequence) && e.hash !== input.hash,
);
if (clash) throw new Error(`Sequence ${input.sequence} already queued as ${clash.hash}`);…or drop the claim from the doc comment. I'd prefer enforcing it — silently queueing a transaction that's guaranteed to fail is worse than rejecting it at the call site.
2. 🚨 Read-modify-write race can silently drop queued transactions
Every mutator follows the same unguarded pattern:
const entries = await this.list(); // ← async read
entries.push(entry);
await this.persist(entries); // ← async writeAsyncStorage is asynchronous, so two overlapping enqueue() calls both read the same array, each appends only its own entry, and the second persist() overwrites the first. One transaction disappears with no error. remove(), clear(), and the mutation flush at lines 243–250 have the identical shape.
For an offline outbox this is the one failure mode that matters — the component exists specifically so transactions aren't lost, and the race is most likely precisely when several are queued in quick succession offline.
A single-flight promise chain is enough, no dependency needed:
private tail: Promise<unknown> = Promise.resolve();
private serialize<T>(fn: () => Promise<T>): Promise<T> {
const run = this.tail.then(fn, fn);
this.tail = run.catch(() => {});
return run;
}Then wrap each mutator body in this.serialize(...). Reads via list() can stay unserialized.
Tests
309 lines of transaction-handling logic with no tests. I know mobile has no runner configured yet — #524/#525 add jest.config.js and #508/#510 add a Mobile — typecheck & test CI job, so that's landing shortly.
Both bugs above are cheap to cover and worth doing as soon as a runner exists:
- enqueue the same hash twice → one entry
- enqueue two hashes at the same sequence → rejected (once #1 is fixed)
- two concurrent
enqueue()calls → both present (this fails today) - replay with a malformed XDR → that entry marked
failed, others untouched
Minor: privacy
Queued entries sit in plaintext AsyncStorage, which is unencrypted on both platforms. Signed XDRs aren't as sensitive as a key — they're already authorized and can't be altered without invalidating the signature — so this isn't the blocker it is on #512. But the envelopes do reveal destinations, amounts, and memos to anything that can read app storage, which is worth thinking about in a wallet called Veil. expo-secure-store has a ~2KB per-item limit so it's not a drop-in for a queue; not asking you to solve it here, just flagging it as a known property worth a comment in the file.
Fix the race and the sequence check and I'll merge this — it's close, and it's nice to review something with no cross-PR entanglement.
# Conflicts: # frontend/mobile/package.json
…vacy note Apply review feedback from Miracle656#519: - Add serialize() promise chain to prevent read-modify-write race on AsyncStorage (the one failure mode that matters for an offline outbox) - Reject enqueue of a sequence number already queued under a different hash (sequence-number uniqueness enforcement) - Add privacy note doc about AsyncStorage plaintext XDR visibility
|
@Miracle656 Thank you for your review, I have implemented the changes you requested, please review and merge |
|
Thanks — closing this, because #450 was resolved on
This branch's Two things in here are better than what landed, and I'd like them as follow-ups rather than losing them: The privacy note. Your header spells out something
That's exactly the right analysis — the threat is disclosure, not tampering, and the reason the obvious fix doesn't work is real. Duplicate-submission defence. You dedup on transaction hash and sequence-number uniqueness. Both are small, well-scoped pieces of work against the existing |
|
@Miracle656 you closed the PR without marking it as completed on drips |
Summary
Ports
sdk/src/outbox.tstofrontend/mobile/lib/outbox.tswith a native persistent backend so queued transactions survive app restarts and replay automatically on reconnect.Changes
frontend/mobile/lib/outbox.ts— NewMobileOutboxclass backed by@react-native-async-storage/async-storage. Mirrors the SDK'sTransactionOutboxbehavior (hash dedup, sequence-number uniqueness, at-most-once replay) but writes directly to AsyncStorage so transactions survive process kills and cold starts.useAutoReplayOutboxhook — Uses@react-native-community/netinfoto listen for connectivity changes. When the device comes back online, any pending entries are automatically replayed against the Soroban RPC. Also replays once on mount to catch stale entries from a previous session.package.json— Added@react-native-async-storage/async-storageand@react-native-community/netinfoas dependencies.How it works
outbox.enqueue({ hash, sequence, xdr, networkPassphrase })before submitting the signed transaction to the network.useAutoReplayOutbox(outbox, rpcUrl)in a top-level component. On connectivity change, pending entries are submitted. Already-confirmed transactions are deduped by hash and never resubmitted.Acceptance
A tx queued while offline replays after app relaunch + network reconnect — the outbox reads pending entries from AsyncStorage and submits them once
NetInforeports connectivity.Refs
sdk/src/outbox.ts