aboutsummaryrefslogtreecommitdiffstats
path: root/test/e2e/custodial-btc-fee-bump.spec.ts
blob: 98561a60236d599d67b889fc4067394e41a1057e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import { expect, test } from "@playwright/test";
import {
  bobUsername,
  bobPassword,
  login,
  getBitcoinAddress,
  pasteAndSend,
  fillNumpadAmount,
  waitForSendComplete,
  apiLogin,
  apiBaseUrl,
} from "./helpers";

test("custodial bitcoin send with low fee shows bump reserve and supports CPFP bump", async ({
  page,
}) => {
  test.setTimeout(120_000);

  await login(page, bobUsername, bobPassword);

  // Check bob has enough balance
  const token = await apiLogin(page, bobUsername, bobPassword);
  const meRes = await page.request.get(`${apiBaseUrl}/me`, {
    headers: { authorization: `Bearer ${token}` },
  });
  const me = await meRes.json();
  if (!me.balance || me.balance < 35000) {
    test.skip(true, `Custodial balance too low: ${me.balance}`);
    return;
  }

  const externalAddress = getBitcoinAddress();

  // --- Send flow: paste address → amount → select low fee → send ---
  await pasteAndSend(page, externalAddress);
  expect(page.url()).toContain("/send/bitcoin/");

  await fillNumpadAmount(page, 2000);
  await page.waitForURL(/\/send\/bitcoin\/[^/]+\/\d+/, { timeout: 10_000 });

  // Check no error on fee page
  const loadError = await page
    .locator(".text-red-600")
    .first()
    .innerText({ timeout: 1000 })
    .catch(() => "");
  expect(loadError, `Fee page error: ${loadError}`).toBeFalsy();

  // The fee page now uses a slider instead of low/mid/high buttons.
  // At the default fee rate, bumpReserve may or may not be set by the server.
  // Check if "Refundable reserve" text appears (indicates bumpReserve > 0).
  const bumpReserveVisible = await page
    .locator("text=Refundable reserve")
    .first()
    .isVisible({ timeout: 5000 })
    .catch(() => false);

  if (!bumpReserveVisible) {
    console.log("[e2e] No refundable reserve shown at default fee rate — skipping bump UI test");
  }

  // Send the transaction
  const sendButton = page.locator('button[type="submit"]');
  await expect(sendButton).toBeVisible({ timeout: 5_000 });
  await sendButton.click();

  await waitForSendComplete(page, 30_000);

  // Extract payment ID from /sent/{id}
  const sentMatch = page.url().match(/\/sent\/([^/?#]+)/);
  expect(sentMatch, "Should be on /sent/ page").toBeTruthy();
  const paymentId = sentMatch![1];

  // --- Navigate to payment detail and verify bump data ---
  await page.goto(`/payment/${paymentId}`);
  await page.waitForLoadState("load");
  await expect(page.locator("h1")).toBeVisible({ timeout: 10_000 });

  // Verify payment has bump reserve via API
  const paymentRes = await page.request.get(`${apiBaseUrl}/payments/${paymentId}`, {
    headers: { authorization: `Bearer ${token}` },
  });
  expect(paymentRes.ok()).toBeTruthy();
  const payment = await paymentRes.json();
  console.log(
    `[e2e] Payment: confirmed=${payment.confirmed}, bumpReserve=${payment.bumpReserve}`,
  );

  if (!payment.bumpReserve || payment.bumpReserve <= 0 || payment.confirmed || payment.childTxid) {
    console.log("[e2e] Payment not bumpable — skipping bump UI test");
    return;
  }

  // --- Test CPFP bump UI ---

  // Wait for hydration
  await page.waitForTimeout(2000);

  // Click "Speed up" button
  const speedUpBtn = page.locator("button.btn-accent", { hasText: "Speed up" });
  await expect(speedUpBtn).toBeVisible({ timeout: 10_000 });
  await speedUpBtn.click();

  // Wait for success toast — the bump is executed directly
  await expect(page.locator("text=Transaction bumped!").first()).toBeVisible({ timeout: 15_000 });
  console.log("[e2e] CPFP bump succeeded!");

  // Verify bump stored via API
  const bumpedRes = await page.request.get(`${apiBaseUrl}/payments/${paymentId}`, {
    headers: { authorization: `Bearer ${token}` },
  });
  const bumped = await bumpedRes.json();
  console.log(`[e2e] After bump: confirmed=${bumped.confirmed}, bumpReserve=${bumped.bumpReserve}`);
});

test("bump API executes CPFP for unconfirmed payment", async ({ page }) => {
  test.setTimeout(60_000);

  await login(page, bobUsername, bobPassword);
  const token = await apiLogin(page, bobUsername, bobPassword);

  // Find an unconfirmed outgoing bitcoin payment with bump reserve
  const paymentsRes = await page.request.get(`${apiBaseUrl}/payments`, {
    headers: { authorization: `Bearer ${token}` },
  });
  expect(paymentsRes.ok()).toBeTruthy();
  const data = await paymentsRes.json();
  const payments = data.payments || data;

  const bumpable = payments.find(
    (p: any) =>
      p.type === "bitcoin" &&
      !p.confirmed &&
      p.amount < 0 &&
      p.bumpReserve > 0 &&
      !p.childTxid,
  );

  if (!bumpable) {
    test.skip(true, "No bumpable unconfirmed bitcoin payment found");
    return;
  }

  console.log(`[e2e] Bumpable payment: ${bumpable.id}, reserve: ${bumpable.bumpReserve}`);

  // Test bump endpoint
  const bumpRes = await page.request.post(`${apiBaseUrl}/bump`, {
    headers: {
      authorization: `Bearer ${token}`,
      "content-type": "application/json",
    },
    data: { id: bumpable.id },
  });

  const bumpText = await bumpRes.text();
  console.log(`[e2e] Bump result: ${bumpRes.status()} ${bumpText.substring(0, 200)}`);
  if (!bumpRes.ok() && bumpText.includes("exceeds reserve")) {
    test.skip(true, "Bump reserve already consumed by prior test");
    return;
  }
  expect(bumpRes.ok(), `Bump failed: ${bumpText}`).toBeTruthy();
});