Skip to content

Commit

Permalink
Merge pull request #2746 from getAlby/feat/signpsbt-v2
Browse files Browse the repository at this point in the history
feat: signPSBT
  • Loading branch information
rolznz committed Oct 9, 2023
2 parents a1a6b4c + 4837209 commit 929ba15
Show file tree
Hide file tree
Showing 27 changed files with 924 additions and 129 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,19 @@
},
"dependencies": {
"@bitcoin-design/bitcoin-icons-react": "^0.1.9",
"@bitcoinerlab/secp256k1": "^1.0.5",
"@getalby/sdk": "^2.2.3",
"@headlessui/react": "^1.7.16",
"@lightninglabs/lnc-web": "^0.2.4-alpha",
"@noble/curves": "^1.1.0",
"@noble/secp256k1": "^2.0.0",
"@scure/bip32": "^1.3.1",
"@scure/bip39": "^1.2.1",
"@scure/btc-signer": "^0.5.1",
"@tailwindcss/forms": "^0.5.4",
"@vespaiach/axios-fetch-adapter": "^0.3.0",
"axios": "^0.27.2",
"bech32": "^2.0.0",
"bitcoinjs-lib": "^6.1.0",
"bolt11": "^1.4.1",
"crypto-js": "^4.1.1",
"dayjs": "^1.11.9",
Expand Down
5 changes: 5 additions & 0 deletions src/app/router/Prompt/Prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import Toaster from "~/app/components/Toast/Toaster";
import Providers from "~/app/context/Providers";
import RequireAuth from "~/app/router/RequireAuth";
import BitcoinConfirmGetAddress from "~/app/screens/Bitcoin/ConfirmGetAddress";
import ConfirmSignPsbt from "~/app/screens/Bitcoin/ConfirmSignPsbt";
import AlbyEnable from "~/app/screens/Enable/AlbyEnable";
import LiquidEnable from "~/app/screens/Enable/LiquidEnable";
import NostrEnable from "~/app/screens/Enable/NostrEnable";
Expand Down Expand Up @@ -109,6 +110,10 @@ function Prompt() {
path="public/webbtc/confirmGetAddress"
element={<BitcoinConfirmGetAddress />}
/>
<Route
path="webbtc/confirmSignPsbt"
element={<ConfirmSignPsbt />}
/>
<Route
path="public/liquid/confirmGetAddress"
element={<LiquidConfirmGetAddress />}
Expand Down
144 changes: 144 additions & 0 deletions src/app/screens/Bitcoin/ConfirmSignPsbt/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import msg from "~/common/lib/msg";
import state from "~/extension/background-script/state";
import { btcFixture } from "~/fixtures/btc";
import type { OriginData } from "~/types";

import { getFormattedSats } from "~/common/utils/currencyConvert";
import Bitcoin from "~/extension/background-script/bitcoin";
import Mnemonic from "~/extension/background-script/mnemonic";
import ConfirmSignPsbt from "./index";

const mockOrigin: OriginData = {
location: "https://getalby.com/demo",
domain: "https://getalby.com",
host: "getalby.com",
pathname: "/demo",
name: "Alby",
description: "",
icon: "https://getalby.com/assets/alby-503261fa1b83c396b7ba8d927db7072d15fea5a84d387a654c5d0a2cefd44604.svg",
metaData: {
title: "Alby Demo",
url: "https://getalby.com/demo",
provider: "Alby",
image:
"https://getalby.com/assets/alby-503261fa1b83c396b7ba8d927db7072d15fea5a84d387a654c5d0a2cefd44604.svg",
icon: "https://getalby.com/favicon.ico",
},
external: true,
};

jest.mock("~/app/hooks/useNavigationState", () => {
return {
useNavigationState: jest.fn(() => ({
origin: mockOrigin,
args: {
psbt: btcFixture.regtestTaprootPsbt,
},
})),
};
});

const passwordMock = jest.fn;

const mockState = {
password: passwordMock,
currentAccountId: "1e1e8ea6-493e-480b-9855-303d37506e97",
getAccount: () => ({
mnemonic: btcFixture.mnemonic,
bitcoinNetwork: "regtest",
}),
getMnemonic: () => new Mnemonic(btcFixture.mnemonic),
getBitcoin: () => new Bitcoin(new Mnemonic(btcFixture.mnemonic), "regtest"),
getConnector: jest.fn(),
};

state.getState = jest.fn().mockReturnValue(mockState);

jest.mock("~/app/context/SettingsContext", () => ({
useSettings: () => ({
getFormattedSats: (amount: number) =>
getFormattedSats({
amount,
locale: "en",
}),
}),
}));

// mock getPsbtPreview request
msg.request = jest
.fn()
.mockReturnValue(
new Bitcoin(new Mnemonic(btcFixture.mnemonic), "regtest").getPsbtPreview(
btcFixture.regtestTaprootPsbt
)
);

describe("ConfirmSignPsbt", () => {
test("render", async () => {
await act(async () => {
render(
<MemoryRouter>
<ConfirmSignPsbt />
</MemoryRouter>
);
});

const user = userEvent.setup();

await act(async () => {
await user.click(screen.getByText("View details"));
});

expect(
await screen.findByText(
"This website asks you to sign a bitcoin transaction"
)
).toBeInTheDocument();

// Check inputs
const inputsContainer = (await screen.getByText("Inputs")
.parentElement) as HTMLElement;
expect(inputsContainer).toBeInTheDocument();
const inputsRef = within(inputsContainer);
expect(
await inputsRef.findByText(
"bcrt1p8wpt9v4frpf3tkn0srd97pksgsxc5hs52lafxwru9kgeephvs7rqjeprhg"
)
).toBeInTheDocument();

// Check outputs
const outputsContainer = screen.getByText("Outputs")
.parentElement as HTMLElement;
expect(outputsContainer).toBeInTheDocument();

const outputsRef = within(outputsContainer);
expect(
await outputsRef.findByText(
"bcrt1p6uav7en8k7zsumsqugdmg5j6930zmzy4dg7jcddshsr0fvxlqx7qnc7l22"
)
).toBeInTheDocument();

expect(
await outputsRef.findByText(
"bcrt1p90h6z3p36n9hrzy7580h5l429uwchyg8uc9sz4jwzhdtuhqdl5eqkcyx0f"
)
).toBeInTheDocument();

// Check fee
const feeContainer = screen.getByText("Fee").parentElement as HTMLElement;
expect(feeContainer).toBeInTheDocument();

const feeRef = within(feeContainer);
expect(await feeRef.findByText("155 sats")).toBeInTheDocument();

await act(async () => {
await user.click(screen.getByText("View raw transaction (Hex)"));
});
expect(
await screen.findByText(btcFixture.regtestTaprootPsbt)
).toBeInTheDocument();
});
});
194 changes: 194 additions & 0 deletions src/app/screens/Bitcoin/ConfirmSignPsbt/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import ConfirmOrCancel from "@components/ConfirmOrCancel";
import Container from "@components/Container";
import PublisherCard from "@components/PublisherCard";
import SuccessMessage from "@components/SuccessMessage";
import { TFunction } from "i18next";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Hyperlink from "~/app/components/Hyperlink";
import Loading from "~/app/components/Loading";
import ScreenHeader from "~/app/components/ScreenHeader";
import toast from "~/app/components/Toast";
import { useSettings } from "~/app/context/SettingsContext";
import { useNavigationState } from "~/app/hooks/useNavigationState";
import { USER_REJECTED_ERROR } from "~/common/constants";
import api from "~/common/lib/api";
import msg from "~/common/lib/msg";
import type { Address, OriginData, PsbtPreview } from "~/types";

function ConfirmSignPsbt() {
const navState = useNavigationState();
const { t: tCommon } = useTranslation("common");
const { t } = useTranslation("translation", {
keyPrefix: "bitcoin.confirm_sign_psbt",
});
const navigate = useNavigate();
const { getFormattedSats } = useSettings();

const psbt = navState.args?.psbt as string;
const origin = navState.origin as OriginData;
const [loading, setLoading] = useState(true);
const [successMessage, setSuccessMessage] = useState("");
const [preview, setPreview] = useState<PsbtPreview | undefined>(undefined);
const [showAddresses, setShowAddresses] = useState(false);
const [showHex, setShowHex] = useState(false);

useEffect(() => {
(async () => {
try {
const preview = await api.bitcoin.getPsbtPreview(psbt);
setPreview(preview);
setLoading(false);
} catch (e) {
console.error(e);
const error = e as { message: string };
const errorMessage = error.message || "Unknown error";
toast.error(`${tCommon("error")}: ${errorMessage}`);
}
})();
}, [origin, psbt, tCommon]);

async function confirm() {
try {
setLoading(true);
const response = await api.bitcoin.signPsbt(psbt);
msg.reply(response);
setSuccessMessage(tCommon("success"));
} catch (e) {
console.error(e);
const error = e as { message: string };
const errorMessage = error.message || "Unknown error";
toast.error(`${tCommon("error")}: ${errorMessage}`);
} finally {
setLoading(false);
}
}

function reject(e: React.MouseEvent<HTMLAnchorElement>) {
e.preventDefault();
msg.error(USER_REJECTED_ERROR);
}

function close(e: React.MouseEvent<HTMLButtonElement>) {
if (navState.isPrompt) {
window.close();
} else {
e.preventDefault();
navigate(-1);
}
}

function toggleShowAddresses() {
setShowAddresses((current) => !current);
}
function toggleShowHex() {
setShowHex((current) => !current);
}

if (!preview) {
return (
<div className="flex w-full h-full justify-center items-center">
<Loading />
</div>
);
}

return (
<div className="h-full flex flex-col overflow-y-auto no-scrollbar">
<ScreenHeader title={t("title")} />
{!successMessage ? (
<Container justifyBetween maxWidth="sm">
<div className="flex flex-col gap-4 mb-4">
<PublisherCard
title={origin.name}
image={origin.icon}
url={origin.host}
/>
<div className="p-4 shadow bg-white dark:bg-surface-02dp rounded-lg overflow-hidden flex flex-col gap-4">
<h2 className="font-medium dark:text-white">
{t("allow_sign", { host: origin.host })}
</h2>
</div>
<div className="flex w-full justify-center">
<Hyperlink onClick={toggleShowAddresses}>
{showAddresses ? t("hide_details") : t("view_details")}
</Hyperlink>
</div>

{showAddresses && (
<>
<div className="p-4 shadow bg-white dark:bg-surface-02dp rounded-lg overflow-hidden flex flex-col gap-4">
<p className="font-medium dark:text-white">{t("inputs")}</p>
<div className="flex flex-col gap-4">
{preview.inputs.map((input) => (
<AddressPreview key={input.address} t={t} {...input} />
))}
</div>
<p className="font-medium dark:text-white">{t("outputs")}</p>
<div className="flex flex-col gap-4">
{preview.outputs.map((output) => (
<AddressPreview key={output.address} t={t} {...output} />
))}
</div>
<p className="font-medium dark:text-white">{t("fee")}</p>
<p className="font-medium text-sm text-gray-500 dark:text-gray-400">
{getFormattedSats(preview.fee)}
</p>
</div>
<div className="flex w-full justify-center">
<Hyperlink onClick={toggleShowHex}>
{showHex
? t("hide_raw_transaction")
: t("view_raw_transaction")}
</Hyperlink>
</div>
</>
)}

{showHex && (
<div className="break-all p-2 mb-4 shadow bg-white rounded-lg dark:bg-surface-02dp text-gray-500 dark:text-gray-400">
{psbt}
</div>
)}
</div>
<ConfirmOrCancel
disabled={loading}
loading={loading}
onConfirm={confirm}
onCancel={reject}
/>
</Container>
) : (
<Container maxWidth="sm">
<PublisherCard
title={origin.name}
image={origin.icon}
url={origin.host}
/>
<SuccessMessage message={successMessage} onClose={close} />
</Container>
)}
</div>
);
}

function AddressPreview({
address,
amount,
t,
}: Address & {
t: TFunction<"translation", "bitcoin.confirm_sign_psbt", "translation">;
}) {
const { getFormattedSats } = useSettings();
return (
<div>
<p className="text-gray-500 dark:text-gray-400 break-all">{address}</p>
<p className="font-medium text-sm text-gray-500 dark:text-gray-400">
{getFormattedSats(amount)}
</p>
</div>
);
}

export default ConfirmSignPsbt;

0 comments on commit 929ba15

Please sign in to comment.