-
Notifications
You must be signed in to change notification settings - Fork 192
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2746 from getAlby/feat/signpsbt-v2
feat: signPSBT
- Loading branch information
Showing
27 changed files
with
924 additions
and
129 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.