Skip to content

Commit 038923c

Browse files
authored
feat: add network fee bump (speed up) for stuck pending transactions (#707) (#866)
Adds the ability to resubmit a Stellar transaction that has been pending longer than 60 seconds with a higher max fee via TransactionBuilder.buildFeeBumpTransaction, and surfaces a 'Speed up' button in the transaction list.
1 parent 29acd38 commit 038923c

5 files changed

Lines changed: 365 additions & 1 deletion

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* stellarWallet.test.ts
3+
*
4+
* Unit tests for the network fee bump helper used to "Speed Up" stuck
5+
* transaction envelopes (#707).
6+
*
7+
* Tests cover:
8+
* - buildFeeBumpEnvelope constructs a FeeBumpTransaction that wraps the
9+
* original (inner) transaction with a higher max fee, using
10+
* TransactionBuilder.buildFeeBumpTransaction.
11+
* - speedUpTransaction pays a bumped (multiplied) max fee and resubmits the
12+
* fee-bumped envelope to the Stellar network, returning the resulting hash.
13+
*/
14+
15+
import AsyncStorage from "@react-native-async-storage/async-storage";
16+
17+
jest.mock("@react-native-async-storage/async-storage", () =>
18+
require("@react-native-async-storage/async-storage/jest/async-storage-mock")
19+
);
20+
21+
// ── Mock @stellar/stellar-sdk ─────────────────────────────────────────────────
22+
23+
const mockBuildFeeBump = jest.fn();
24+
const mockFeeBumpSign = jest.fn();
25+
const mockFeeBumpToXDR = jest.fn(() => "fee-bump-xdr");
26+
const mockFetchBaseFee = jest.fn();
27+
const mockSubmitTransaction = jest.fn();
28+
const mockKeypairFromSecret = jest.fn();
29+
30+
jest.mock("@stellar/stellar-sdk", () => {
31+
class MockTransaction {
32+
xdr: string;
33+
networkPassphrase: string;
34+
constructor(xdr: string, networkPassphrase: string) {
35+
this.xdr = xdr;
36+
this.networkPassphrase = networkPassphrase;
37+
}
38+
}
39+
return {
40+
__esModule: true,
41+
Transaction: MockTransaction,
42+
TransactionBuilder: {
43+
buildFeeBumpTransaction: (...args: unknown[]) => {
44+
mockBuildFeeBump(...args);
45+
return {
46+
sign: mockFeeBumpSign,
47+
toXDR: mockFeeBumpToXDR,
48+
};
49+
},
50+
// Auto-detects fee bump vs regular envelopes from the XDR and returns a
51+
// plain object the Horizon server accepts (mirrors the SDK's behaviour).
52+
fromXDR: (xdr: string) => ({ __fromXDR: xdr }),
53+
},
54+
Horizon: {
55+
Server: jest.fn().mockImplementation(() => ({
56+
fetchBaseFee: mockFetchBaseFee,
57+
submitTransaction: mockSubmitTransaction,
58+
})),
59+
},
60+
Keypair: {
61+
random: jest.fn(),
62+
fromSecret: mockKeypairFromSecret,
63+
},
64+
Networks: { PUBLIC: "public-passphrase", TESTNET: "testnet-passphrase" },
65+
BASE_FEE: 100,
66+
xdr: {},
67+
};
68+
});
69+
70+
// ── Imports (after mocks) ─────────────────────────────────────────────────────
71+
72+
import {
73+
buildFeeBumpEnvelope,
74+
speedUpTransaction,
75+
SPEED_UP_FEE_MULTIPLIER,
76+
} from "../src/services/stellarWallet";
77+
78+
const FEE_SOURCE = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
79+
const INNER_XDR = "AAAAAgAAAABh...inner-tx...";
80+
const LOCAL_SECRET = "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBB";
81+
82+
const mockFeeKeypair = {
83+
publicKey: jest.fn(() => FEE_SOURCE),
84+
sign: jest.fn(() => "sig"),
85+
};
86+
87+
// ── Helpers ───────────────────────────────────────────────────────────────────
88+
89+
beforeEach(async () => {
90+
jest.clearAllMocks();
91+
await AsyncStorage.clear();
92+
mockFetchBaseFee.mockResolvedValue(100);
93+
mockSubmitTransaction.mockResolvedValue({ hash: "bumped-hash-123" });
94+
mockKeypairFromSecret.mockReturnValue(mockFeeKeypair);
95+
});
96+
97+
// ─ buildFeeBumpEnvelope ───────────────────────────────────────────────────────
98+
99+
describe("buildFeeBumpEnvelope", () => {
100+
it("builds a FeeBumpTransaction wrapping the inner tx with a higher fee", () => {
101+
const result = buildFeeBumpEnvelope(INNER_XDR, FEE_SOURCE, "200");
102+
103+
// Constructed via the SDK's buildFeeBumpTransaction helper (#707 guidance).
104+
expect(mockBuildFeeBump).toHaveBeenCalledTimes(1);
105+
const [feeSource, baseFee, innerTx, passphrase] = mockBuildFeeBump.mock
106+
.calls[0] as [string, string, { xdr: string }, string];
107+
108+
expect(feeSource).toBe(FEE_SOURCE);
109+
expect(baseFee).toBe("200");
110+
// The inner transaction is parsed from the original envelope XDR.
111+
expect(innerTx.xdr).toBe(INNER_XDR);
112+
expect(passphrase).toBe("testnet-passphrase");
113+
// returns the FeeBumpTransaction object
114+
expect(result).toBeDefined();
115+
});
116+
117+
it("passes a higher max fee than the supplied base fee", () => {
118+
buildFeeBumpEnvelope(INNER_XDR, FEE_SOURCE, "500");
119+
const [, baseFee] = mockBuildFeeBump.mock.calls[0] as [string, string];
120+
expect(Number(baseFee)).toBeGreaterThan(100);
121+
});
122+
});
123+
124+
// ─ speedUpTransaction ─────────────────────────────────────────────────────────
125+
126+
describe("speedUpTransaction", () => {
127+
it("resubmits a fee-bumped envelope using a multiplied fee and returns the hash", async () => {
128+
mockFetchBaseFee.mockResolvedValue(150);
129+
await AsyncStorage.setItem("stellar_keypair", LOCAL_SECRET);
130+
131+
const hash = await speedUpTransaction(INNER_XDR, "local");
132+
133+
// Fee is bumped by the multiplier over the network base fee.
134+
const [, baseFee] = mockBuildFeeBump.mock.calls[0] as [string, string];
135+
expect(mockFetchBaseFee).toHaveBeenCalled();
136+
expect(Number(baseFee)).toBe(
137+
150 * SPEED_UP_FEE_MULTIPLIER
138+
);
139+
140+
// The fee-bump transaction is signed with the local keypair.
141+
expect(mockFeeBumpSign).toHaveBeenCalledWith(mockFeeKeypair);
142+
143+
// The bumped envelope is resubmitted to the Stellar network.
144+
expect(mockSubmitTransaction).toHaveBeenCalledTimes(1);
145+
expect(hash).toBe("bumped-hash-123");
146+
});
147+
148+
it("resolves the fee source from the local keypair when not provided", async () => {
149+
await AsyncStorage.setItem("stellar_keypair", LOCAL_SECRET);
150+
151+
await speedUpTransaction(INNER_XDR, "local");
152+
153+
const [feeSource] = mockBuildFeeBump.mock.calls[0] as [string];
154+
expect(feeSource).toBe(FEE_SOURCE);
155+
});
156+
});

‎mobileapp/app/(personal)/history.tsx‎

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,40 @@ import { useTransactions } from "../../src/hooks/useTransactions";
1818
import { TransactionFilterSheet } from "../../src/components/TransactionFilterSheet";
1919
import { Transaction, TransactionFilters } from "../../src/types/transaction";
2020
import { formatDate } from "../../src/utils/formatting";
21+
import {
22+
speedUpTransaction,
23+
getLocalKeypair,
24+
checkFreighter,
25+
} from "../../src/services/stellarWallet";
26+
27+
// ── Constants ─────────────────────────────────────────────────────────────────
28+
29+
/** A pending transaction is considered "stuck" (and eligible to Speed Up) once
30+
* it has been pending longer than this, in milliseconds. */
31+
const STUCK_PENDING_MS = 60 * 1000;
2132

2233
// ── Helpers ───────────────────────────────────────────────────────────────────
2334

35+
/**
36+
* A pending transaction can be sped up once it has been pending for more than a
37+
* minute and we hold the original envelope XDR needed to build a fee bump.
38+
*/
39+
function canSpeedUp(tx: Transaction): boolean {
40+
if (tx.status !== "pending" || !tx.stellarTxXdr) return false;
41+
return Date.now() - new Date(tx.timestamp).getTime() > STUCK_PENDING_MS;
42+
}
43+
44+
/** Resolve the currently connected wallet backend, preferring the local
45+
* keypair (this app's bundled demo wallet) and falling back to Freighter. */
46+
async function resolveWalletSource(): Promise<
47+
"freighter" | "albedo" | "local"
48+
> {
49+
const kp = await getLocalKeypair();
50+
if (kp) return "local";
51+
if (await checkFreighter()) return "freighter";
52+
return "local";
53+
}
54+
2455
function statusColor(status: Transaction["status"]): string {
2556
switch (status) {
2657
case "completed":
@@ -90,9 +121,13 @@ function hasActiveFilters(f: TransactionFilters): boolean {
90121
const TransactionRow = React.memo(function TransactionRow({
91122
item,
92123
onPress,
124+
onSpeedUp,
125+
speedingUp,
93126
}: {
94127
item: Transaction;
95128
onPress: () => void;
129+
onSpeedUp: (tx: Transaction) => void;
130+
speedingUp: boolean;
96131
}) {
97132
const icon = typeIcon(item.type);
98133
const isOutgoing = item.type === "sent";
@@ -105,6 +140,7 @@ const TransactionRow = React.memo(function TransactionRow({
105140
hour: "2-digit",
106141
minute: "2-digit",
107142
});
143+
const showSpeedUp = canSpeedUp(item);
108144

109145
return (
110146
<TouchableOpacity
@@ -167,6 +203,30 @@ const TransactionRow = React.memo(function TransactionRow({
167203
</View>
168204
)}
169205
</View>
206+
{showSpeedUp && (
207+
<TouchableOpacity
208+
style={styles.speedUpBtn}
209+
onPress={() => onSpeedUp(item)}
210+
disabled={speedingUp}
211+
activeOpacity={0.8}
212+
accessibilityRole="button"
213+
accessibilityLabel={`Speed up ${item.amount} ${item.asset}`}
214+
>
215+
{speedingUp ? (
216+
<ActivityIndicator size="small" color="#fff" />
217+
) : (
218+
<>
219+
<Ionicons
220+
name="speedometer-outline"
221+
size={13}
222+
color="#fff"
223+
style={{ marginRight: 5 }}
224+
/>
225+
<Text style={styles.speedUpBtnText}>Speed up</Text>
226+
</>
227+
)}
228+
</TouchableOpacity>
229+
)}
170230
</View>
171231

172232
{/* Amount */}
@@ -287,6 +347,29 @@ export default function HistoryScreen() {
287347
typeof setTimeout
288348
> | null>(null);
289349

350+
// Id of the transaction currently being sped up (for per-row loading state).
351+
const [speedingUpId, setSpeedingUpId] = useState<string | null>(null);
352+
353+
const handleSpeedUp = useCallback(
354+
async (tx: Transaction) => {
355+
if (!tx.stellarTxXdr) return;
356+
setSpeedingUpId(tx.id);
357+
try {
358+
const source = await resolveWalletSource();
359+
await speedUpTransaction(tx.stellarTxXdr, source);
360+
if (global.toast) global.toast.success("Transaction sped up");
361+
// Reflect the new on-chain state.
362+
reload();
363+
} catch (e) {
364+
const msg = e instanceof Error ? e.message : "Could not speed up";
365+
if (global.toast) global.toast.error(msg);
366+
} finally {
367+
setSpeedingUpId(null);
368+
}
369+
},
370+
[reload]
371+
);
372+
290373
// Initial load
291374
useEffect(() => {
292375
reload();
@@ -328,10 +411,12 @@ export default function HistoryScreen() {
328411
<TransactionRow
329412
item={item.tx}
330413
onPress={() => router.push(`/transaction/${item.tx.id}` as any)}
414+
onSpeedUp={handleSpeedUp}
415+
speedingUp={speedingUpId === item.tx.id}
331416
/>
332417
);
333418
},
334-
[router]
419+
[router, handleSpeedUp, speedingUpId]
335420
);
336421

337422
const ListFooter = () => {
@@ -704,6 +789,24 @@ const styles = StyleSheet.create({
704789
paddingVertical: 2,
705790
borderRadius: 100,
706791
},
792+
speedUpBtn: {
793+
flexDirection: "row",
794+
alignItems: "center",
795+
alignSelf: "flex-start",
796+
marginTop: 8,
797+
paddingHorizontal: 12,
798+
paddingVertical: 6,
799+
borderRadius: 100,
800+
backgroundColor: COLORS.primary,
801+
minWidth: 92,
802+
justifyContent: "center",
803+
},
804+
speedUpBtnText: {
805+
fontSize: 12,
806+
fontFamily: "Outfit_600SemiBold",
807+
color: "#fff",
808+
textTransform: "capitalize",
809+
},
707810
statusPillText: {
708811
fontSize: 11,
709812
fontFamily: "Outfit_600SemiBold",

0 commit comments

Comments
 (0)