Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,86 @@ const { wallet: next, ledger: nextLedger, adopted, dropped } = reconcile(
All helpers are pure: inputs → new state. No I/O, and time-sensitive helpers
accept an injectable `now` option for deterministic tests; otherwise they use
the current time by default.

### Canonical write pattern: two-phase broadcast

`LedgerEntry.status` captures whether a broadcast has round-tripped with the
node. The two-phase contract closes the edge-terminator / crash window where a
single-phase ledger write could claim `broadcast_sent` for a tx the node never
saw (or lose track of a tx the node did accept).

```
(no entry) → pending_broadcast [beginPendingBroadcast]
pending_broadcast → broadcast_sent [resolveBroadcast("sent") | reconcile]
pending_broadcast → broadcast_failed [resolveBroadcast("failed")]
broadcast_sent → pending_broadcast [new RBF attempt, new txId]
broadcast_failed → pending_broadcast [retry, new txId]
```

Write the ledger **before** the network call, resolve on return:

```ts
import {
beginPendingBroadcast,
resolveBroadcast,
reconcile,
} from "@aibtc/tx-schemas/core";

ledger = beginPendingBroadcast(ledger, {
nonce,
txId,
fee,
});

try {
const outcome = await broadcastTransaction(signedTx);
ledger = resolveBroadcast(ledger, nonce, "sent", { lastOutcome: outcome });
} catch (err) {
ledger = resolveBroadcast(ledger, nonce, "failed");
throw err;
}
```

For RBF (e.g., a `fee_too_low` outcome), pass the incremented attempt count
explicitly — `beginPendingBroadcast` does not auto-increment, since the same
helper is also used for first broadcasts and retries:

```ts
const existing = ledger.entries[String(nonce)]!;
ledger = beginPendingBroadcast(ledger, {
nonce,
txId: rbfTxId,
fee: bumpedFee,
rbfAttempts: existing.rbfAttempts + 1,
});
```

`decideBroadcast` refuses to issue a new decision while the entry is
`pending_broadcast`. It returns:

```ts
{ kind: "await_pending_broadcast", nonce, txId }
```

so the consumer resolves the prior call before a second broadcast can fire.

`reconcile()` sweeps survivors of crashes that dropped the resolve step:

- A `pending_broadcast` entry whose txId appears in the mempool is promoted
to `broadcast_sent` automatically.
- A `pending_broadcast` entry absent from the mempool within
`justBroadcastGraceSeconds` (default 30) is classified as
`inFlightPendingIndex` — node may have accepted it; indexer just hasn't
caught up.
- Past the grace window with no mempool hit, the entry is reported as
`dropped` for caller inspection.

```ts
const { ledger: next, adopted, dropped, inFlightPendingIndex } = reconcile(
wallet,
ledger,
mempoolReadByNonce,
sponsorAddress,
{ justBroadcastGraceSeconds: 30 }
);
```
16 changes: 16 additions & 0 deletions src/core/sponsor-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,28 @@ import {
// One entry per (sponsorAddress, nonce). Relays persist this so they can
// distinguish their own prior broadcast (RBF path) from a foreign occupant
// (quarantine path) when stacks-core reports a nonce conflict.
//
// Lifecycle (two-phase broadcast):
// (no entry) → pending_broadcast [beginPendingBroadcast]
// pending_broadcast → broadcast_sent [resolveBroadcast / reconcile]
// pending_broadcast → broadcast_failed [resolveBroadcast]
// broadcast_sent → pending_broadcast [new RBF attempt, new txId]
// broadcast_failed → pending_broadcast [retry, new txId]
// ---------------------------------------------------------------------------

export const LedgerEntryStatusSchema = z.enum([
"pending_broadcast",
"broadcast_sent",
"broadcast_failed",
]);

export type LedgerEntryStatus = z.infer<typeof LedgerEntryStatusSchema>;

export const SponsorLedgerEntrySchema = z.object({
nonce: NonNegativeIntegerSchema,
txId: TransactionIdSchema,
fee: AmountStringSchema,
status: LedgerEntryStatusSchema,
broadcastAt: IsoDateTimeSchema,
rbfAttempts: NonNegativeIntegerSchema,
lastOutcome: NodeBroadcastOutcomeSchema.optional(),
Expand Down
Loading