Skip to content

feat(mobile): AsyncStorage-backed offline tx outbox with auto-replay (#450) - #519

Closed
northvictor wants to merge 3 commits into
Miracle656:mainfrom
northvictor:feat/offline-tx-outbox-persistence
Closed

feat(mobile): AsyncStorage-backed offline tx outbox with auto-replay (#450)#519
northvictor wants to merge 3 commits into
Miracle656:mainfrom
northvictor:feat/offline-tx-outbox-persistence

Conversation

@northvictor

Copy link
Copy Markdown
Contributor

Summary

Ports sdk/src/outbox.ts to frontend/mobile/lib/outbox.ts with a native persistent backend so queued transactions survive app restarts and replay automatically on reconnect.

Changes

  • frontend/mobile/lib/outbox.ts — New MobileOutbox class backed by @react-native-async-storage/async-storage. Mirrors the SDK's TransactionOutbox behavior (hash dedup, sequence-number uniqueness, at-most-once replay) but writes directly to AsyncStorage so transactions survive process kills and cold starts.
  • useAutoReplayOutbox hook — Uses @react-native-community/netinfo to 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-storage and @react-native-community/netinfo as dependencies.

How it works

  1. Enqueue before send — Call outbox.enqueue({ hash, sequence, xdr, networkPassphrase }) before submitting the signed transaction to the network.
  2. Auto-replay — Mount 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.
  3. Survives restart — AsyncStorage persists the queue across app relaunches. On next launch the hook immediately replays any stale entries.

Acceptance

A tx queued while offline replays after app relaunch + network reconnect — the outbox reads pending entries from AsyncStorage and submits them once NetInfo reports connectivity.

Refs

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
northvictor requested a review from Miracle656 as a code owner July 28, 2026 00:09
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

@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.

@drips-wave

drips-wave Bot commented Jul 28, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 write

AsyncStorage 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
@northvictor
northvictor requested a review from Miracle656 July 29, 2026 01:18
@northvictor

Copy link
Copy Markdown
Contributor Author

@Miracle656 Thank you for your review, I have implemented the changes you requested, please review and merge

@Miracle656

Copy link
Copy Markdown
Owner

Thanks — closing this, because #450 was resolved on main by #542 while this was open, and merging now would leave the app with two competing outboxes.

frontend/mobile/lib/outbox.ts already exists on main: AsyncStorage-backed under veil_outbox_v1, with hydrateOutbox, enqueueOutboxAction, flushOutbox, subscribeOutbox and a MAX_ATTEMPTS retry cap. Crucially it's already wired for the acceptance criterion — lib/connectivity.tsx calls hydrateOutbox() on mount and flushOutbox() when connectivity returns, so a queued action survives relaunch and replays on reconnect. It's covered by 15 cases in lib/__tests__/outbox.test.ts.

This branch's MobileOutbox class plus useAutoReplayOutbox hook isn't imported anywhere, so on its own it wouldn't replay anything — it would need the same wiring main's version already has.

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 main's version doesn't acknowledge:

Queued entries (signed XDR envelopes) are stored in plaintext AsyncStorage. XDRs reveal destinations, amounts, and memos to anything with filesystem access to the app's storage. They cannot be altered without invalidating the embedded signature, so an attacker cannot forge or modify queued transactions — only read them. expo-secure-store's ~2 KB per-item limit prevents it as a drop-in replacement for the queue.

That's exactly the right analysis — the threat is disclosure, not tampering, and the reason the obvious fix doesn't work is real. main's outbox takes an opaque payload: unknown, so the same exposure applies the moment anything queues a signed envelope through it. Worth opening as its own issue, with that reasoning carried across verbatim.

Duplicate-submission defence. You dedup on transaction hash and sequence-number uniqueness. main's version has an attempt counter but no dedup, so a flush that fails after the network accepted the transaction could resubmit. Also worth its own issue.

Both are small, well-scoped pieces of work against the existing outbox.ts — happy to review either.

@Miracle656 Miracle656 closed this Jul 30, 2026
@northvictor

Copy link
Copy Markdown
Contributor Author

@Miracle656 you closed the PR without marking it as completed on drips

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

22. Offline tx queue (outbox)

2 participants