diff --git a/bun.lockb b/bun.lockb index cb964f43..bad76ce2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 1287b748..9acf6747 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "next lint" }, "dependencies": { - "@bsv/sdk": "^1.1.19", + "@bsv/sdk": "^1.1.21", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@google/model-viewer": "^3.5.0", @@ -34,9 +34,11 @@ "@vercel/speed-insights": "^1.0.12", "ai": "^3.1.15", "blurhash": "^2.0.5", + "bsv-bap": "0.0.2", "bun-copy-plugin": "^0.2.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "color-hash": "^2.0.2", "critters": "^0.0.20", "crypto-es": "^2.1.0", "daisyui": "^4.11.1", @@ -44,7 +46,7 @@ "framer-motion": "^11.1.7", "isomorphic-dompurify": "^2.4.0", "jdenticon": "^3.2.0", - "js-1sat-ord": "^0.1.73", + "js-1sat-ord": "^0.1.74", "lodash": "^4.17.21", "lucide-react": "^0.378.0", "mime": "^3.0.0", @@ -64,18 +66,18 @@ "react-slick": "^0.30.2", "satoshi-token": "^0.0.4", "sharp": "^0.33.5", - "sigma-protocol": "^0.1.3", + "sigma-protocol": "^0.1.4", "slick-carousel": "^1.8.1", "styled-components": "^6.1.8", "tailwind-merge": "^2.2.1", "text-encoder-lite": "^2.0.0", "three": "^0.163.0", - "upgradeps": "^2.0.6", "usehooks-ts": "^2.9.4", "uuid": "^9.0.1" }, "devDependencies": { "@types/bun": "^1.1.1", + "@types/color-hash": "^2.0.0", "@types/node": "^20", "@types/randombytes": "^2.0.3", "@types/react": "^18.3", diff --git a/src/app/api/identity/get/route.ts b/src/app/api/identity/get/route.ts new file mode 100644 index 00000000..2f482c20 --- /dev/null +++ b/src/app/api/identity/get/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; + +export async function POST(req: Request) { + const { idKey } = await req.json(); + try { + const response = await fetch( + "https://go-bap-indexer-production.up.railway.app/v1/identity/get", + + { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ idKey: idKey }), + } + ); + const data = await response.json(); + return NextResponse.json(data); + } catch (err) { + return new NextResponse(null, { + status: 500, + statusText: "Internal Server Error", + }); + } +} diff --git a/src/app/api/identity/validByAddress/route.ts b/src/app/api/identity/validByAddress/route.ts new file mode 100644 index 00000000..a4e7253f --- /dev/null +++ b/src/app/api/identity/validByAddress/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; + +export async function POST(req: Request) { + const { address } = await req.json(); + try { + const response = await fetch( + "https://go-bap-indexer-production.up.railway.app/v1/identity/validByAddress", + + { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ address: address }), + } + ); + const data = await response.json(); + return NextResponse.json(data); + } catch (err) { + return new NextResponse(null, { + status: 500, + statusText: "Internal Server Error", + }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2c9f406b..68573f7f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,7 +6,7 @@ import TanstackProvider from "@/providers/TanstackProvider"; import type { Metadata } from "next"; import { Analytics } from '@vercel/analytics/react'; import { SpeedInsights } from "@vercel/speed-insights/next" -import { Inter, Ubuntu, Ubuntu_Mono } from "next/font/google"; +import { Inter, Ubuntu } from "next/font/google"; import { Toaster } from "react-hot-toast"; import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); @@ -17,12 +17,6 @@ const ubuntu = Ubuntu({ subsets: ["latin"], }); -const ubuntuMono = Ubuntu_Mono({ - style: "normal", - weight: ["400", "700"], - subsets: ["latin"], -}); - const description = "A open token protocol in the spirit of BTC Ordinals with the cost efficiency and performance of Bitcoin SV."; @@ -38,7 +32,6 @@ export const metadata: Metadata = { }; // get pathname - export default function RootLayout({ children, }: { @@ -57,7 +50,6 @@ export default function RootLayout({ />
- {/* */} {children} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 00000000..c4ddc853 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,29 @@ + +import ProfilePage from "@/components/pages/profile"; +import { Suspense } from "react"; + +const Profile = () => { + return ( + + + + ); +}; +export default Profile; + +export async function generateMetadata() { + return { + title: "Profile Page - 1SatOrdinals", + description: "View your profile information.", + openGraph: { + title: "Profile Page - 1SatOrdinals", + description: "View your profile information.", + type: "website", + }, + twitter: { + card: "ummary_large_image", + title: "Profile Page - 1SatOrdinals", + description: "Profile page on 1SatOrdinals.", + }, + }; +} diff --git a/src/components/Passphrase/index.tsx b/src/components/Passphrase/index.tsx index b8117556..2de6a3d2 100644 --- a/src/components/Passphrase/index.tsx +++ b/src/components/Passphrase/index.tsx @@ -3,14 +3,9 @@ import { encryptionPrefix, toastErrorProps, toastProps } from "@/constants"; import { ImportWalletFromBackupJsonStep, - changeAddressPath, encryptionKey, - identityAddressPath, - identityPk, importWalletFromBackupJsonStep, migrating, - mnemonic, - ordAddressPath, ordPk, passphrase, payPk, @@ -26,13 +21,15 @@ import { backupKeys } from "@/utils/wallet"; import { PrivateKey } from "@bsv/sdk"; import { effect, useSignal } from "@preact/signals-react"; import { useSignals } from "@preact/signals-react/runtime"; -import randomBytes from "randombytes"; import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import toast from "react-hot-toast"; import { FiCopy } from "react-icons/fi"; import { RiErrorWarningFill } from "react-icons/ri"; import { TbDice } from "react-icons/tb"; import { useCopyToClipboard } from "usehooks-ts"; +import { hasIdentityBackup } from "@/signals/bapIdentity/index"; +import { loadAvailableIdentitiesFromEncryptedStorage, loadIdentityFromEncryptedStorage } from "@/signals/bapIdentity/client"; +import randomBytes from "randombytes"; type Props = { mode: EncryptDecrypt; @@ -111,28 +108,22 @@ const EnterPassphrase: React.FC = ({ return; } - const iv = new Uint8Array(randomBytes(16).buffer); + const plainKeys = { + payPk: payPk.value, + ordPk: ordPk.value, + }; + debugger; const encrypted = await encryptData( Buffer.from( - JSON.stringify({ - mnemonic: mnemonic.value, - payPk: payPk.value, - ordPk: ordPk.value, - payDerivationPath: changeAddressPath.value, - ordDerivationPath: ordAddressPath.value, - ...(!!identityPk.value && { identityPk: identityPk.value }), - ...(!!identityAddressPath.value && { identityDerivationPath: identityAddressPath.value }), - }), + JSON.stringify(plainKeys), "utf-8" ), encryptionKey.value, - iv ); - const encryptedBackup = - encryptionPrefix + - Buffer.concat([iv, encrypted]).toString("base64"); - + console.log({encryptionPrefix, encrypted}) + const encryptedBackup = `${encryptionPrefix}${encrypted}`; + debugger; const keys: EncryptedBackupJson = { encryptedBackup, pubKey, @@ -163,27 +154,20 @@ const EnterPassphrase: React.FC = ({ toast.error("Failed to encrypt keys", toastErrorProps); } } - }, [ - download, - encryptionKey.value, - hasDownloadedKeys, - migrating.value, - ordPk.value, - passphrase.value, - payPk.value, - mnemonic.value, - changeAddressPath.value, - ordAddressPath.value, - identityPk.value, - identityAddressPath.value - ]); + }, [download, encryptionKey.value, hasDownloadedKeys, migrating.value, ordPk.value, passphrase.value, payPk.value]); const handleClickDecrypt = async () => { if (passphrase.value) { - console.log("decrypt keys w passphrase"); - try { - await loadKeysFromEncryptedStorage(passphrase.value); + const succeeded = await loadKeysFromEncryptedStorage(passphrase.value); + if (succeeded && !!hasIdentityBackup.value) { + const decryptedId = await loadIdentityFromEncryptedStorage(passphrase.value); + const decryptedIdentities = await loadAvailableIdentitiesFromEncryptedStorage(passphrase.value); + + if (!decryptedId || !decryptedIdentities) { + toast.error("Failed to decrypt identity, please import it again.", toastErrorProps); + } + } onSubmit(); } catch (e) { console.error(e); @@ -269,16 +253,6 @@ const EnterPassphrase: React.FC = ({
- {!migrating.value && !download && ( - - )} - - -
- -
1Sat Wallet
-
    - {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} -
  • { - showDropdown.value = false; - }} - > - - Ordinals - -
  • - {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} -
  • { - showDropdown.value = false; - }} - > - BSV20 -
  • - {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} -
  • { - showDropdown.value = false; - }} - > - BSV21 -
  • -
  • - -
  • -
+ useSignals(); + const router = useRouter(); -
Keys
-
    -
  • - -
  • -
  • - -
  • - {/*
  • + const fetchRateStatus = useSignal(FetchStatus.Idle); + const showWithdrawalModal = useSignal(false); + const showImportWalletModal = useSignal(false); + const showManageProfileModal = useSignal(false); + const showProtectKeysModal = useSignal(false); + const showDropdown = useSignal(false); + + const [eb] = useLocalStorage("encryptedBackup"); + + useEffect(() => { + if (!activeBapIdentity.value) { + loadIdentityFromSessionStorage(); + } + }, [activeBapIdentity.value]); + + useEffect(() => { + hasIdentityBackup.value = Boolean( + localStorage.getItem("encryptedIdentity"), + ); + }, []); + + const [value, copy] = useCopyToClipboard(); + const ordAddressHover = useSignal(false); + + const mouseEnterOrdAddress = () => { + ordAddressHover.value = true; + }; + + const mouseLeaveOrdAddress = () => { + console.log("mouseLeaveOrdAddress"); + ordAddressHover.value = false; + }; + + // useEffect needed so that we can use localStorage + useEffect(() => { + if (payPk.value && ordPk.value) { + const localTxsStr = localStorage.getItem("1satpt"); + const localTxs = localTxsStr ? JSON.parse(localTxsStr) : null; + if (localTxs) { + pendingTxs.value = localTxs as PendingTransaction[]; + } + } + }, [ordPk.value, payPk.value]); + + useEffect(() => { + loadKeysFromSessionStorage(); + + if (eb && !encryptedBackup.value) { + // TODO: This is triggering upon signout! Fix this + // Reproduce: Sign in, encrypt backup + // close tab and reopen + // unlock wallet + // sign out - this will fire forcing the unlock button to show again! + console.log("showing unlock wallet button on purpose"); + showUnlockWalletButton.value = true; + } + }, [encryptedBackup.value, eb]); + + useEffect(() => { + if ( + !!localStorage.getItem(OLD_PAY_PK_KEY) && + !!localStorage.getItem(OLD_ORD_PK_KEY) + ) { + hasUnprotectedKeys.value = true; + } + }, []); + + const balance = computed(() => { + if (!utxos.value) { + return 0; + } + return utxos.value.reduce((acc, utxo) => acc + utxo.satoshis, 0); + }); + + useEffect(() => { + const address = ordAddress.value; + const fire = async () => { + bsv20Balances.value = []; + try { + const { promise } = http.customFetch( + `${MARKET_API_HOST}/user/${address}/balance`, + ); + const u = await promise; + bsv20Balances.value = u.sort((a, b) => { + return b.all.confirmed + b.all.pending > + a.all.confirmed + a.all.pending + ? 1 + : -1; + }); + } catch (e) { + console.log(e); + } + }; + + // console.log({ bsvWasmReady, address, bsv20Balances }) + if (address && !bsv20Balances.value) { + fire(); + } + }, [ordAddress.value, bsv20Balances.value]); + + useEffect(() => { + const fire = async () => { + fetchRateStatus.value = FetchStatus.Loading; + const statusUrl = `${MARKET_API_HOST}/status`; + const { promise: promiseStatus } = http.customFetch<{ + exchangeRate: number; + chainInfo: ChainInfo; + indexers: IndexerStats; + }>(statusUrl); + const { + chainInfo: info, + exchangeRate: er, + indexers: indx, + } = await promiseStatus; + fetchRateStatus.value = FetchStatus.Success; + // console.log({ info, exchangeRate, indexers }); + chainInfo.value = info; + usdRate.value = toSatoshi(1) / er; + exchangeRate.value = er; + indexers.value = indx; + }; + if (fetchRateStatus.value === FetchStatus.Idle) { + fire(); + } + }, [fetchRateStatus]); + + useEffect(() => { + const fire = async (a: string) => { + utxos.value = []; + utxos.value = await getUtxos(a); + }; + + if (fundingAddress && !utxos.value) { + const address = fundingAddress.value; + if (address) { + fire(address); + } + } + }, [fundingAddress.value, utxos.value]); + + // const importKeys = (e: SyntheticEvent) => { + // e.preventDefault(); + // const el = document.getElementById("backupFile"); + // el?.click(); + // return; + // }; + + const handleFileChange = async (e: FileEvent) => { + console.log("handleFileChange called", e.target.files[0]); + if (payPk.value || ordPk.value) { + const c = confirm( + "Are you sure you want to import this wallet? Doing so will replace your existing keys so be sure to back them up first.", + ); + if (!c) { + return; + } + } + await loadKeysFromBackupFiles(e.target.files[0]); + showDropdown.value = false; + router?.push("/wallet"); + }; + + const handleUnlockWallet = () => { + showUnlockWalletModal.value = true; + showDropdown.value = false; + }; + + const handleImportWallet = () => { + showImportWalletModal.value = true; + showDropdown.value = false; + }; + + const handleProtectKeys = () => { + showProtectKeysModal.value = true; + showDropdown.value = false; + }; + + const handleManageProfile = () => { + showManageProfileModal.value = true; + showDropdown.value = false; + }; + + const identityColor = + activeBapIdentity.value && hashColor(activeBapIdentity.value.idKey); + + const idImage = + activeBapIdentity.value?.identity?.image || + activeBapIdentity.value?.identity?.logo; + const identityImage = idImage && getImageFromGP(idImage); + + return ( +
      + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
      { + showDropdown.value = true; + }} + > +
      + {identityImage ? ( + Profile image + ) : ( + + )} +
      +
      + {showDropdown.value && ( + // biome-ignore lint/a11y/useKeyWithClickEvents: +
        + tabIndex={0} + onClick={() => { + showDropdown.value = false; + }} + className="pt-16 md:pt-0 md:dropdown-content menu z-20 md:shadow w-screen overflow-auto md:overflow-none md:h-auto h-screen fixed top-0 left-0 md:left-auto md:top-auto md:relative md:border-yellow-200/25 bg-base-100 md:rounded-box md:w-64 md:border" + > +
        + { + showDropdown.value = false; + }} + /> +
        + {payPk.value && ordPk.value && ( +
        +
        +
        Balance
        +
        + {balance.value === undefined ? ( + "" // user has no wallet yet + ) : usdRate.value > 0 ? ( + `$${(balance.value / usdRate.value).toFixed(2)}` + ) : ( + + )} + USD +
        +
        + {toBitcoin(balance.value)}{" "} + BSV +
        +
        +
        + + +
        + +
        Profile
        +
          +
        • + +
        • + {!activeBapIdentity.value && ( +
        • + + Create Profile + +
        • + )} + {activeBapIdentity.value && ( +
        • + Profile Details +
        • + )} +
        + +
        1Sat Wallet
        +
          + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
        • { + showDropdown.value = false; + }} + > + Ordinals +
        • + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
        • { + showDropdown.value = false; + }} + > + BSV20 +
        • + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
        • { + showDropdown.value = false; + }} + > + BSV21 +
        • +
        • + +
        • +
        + +
        Keys
        +
          +
        • + +
        • +
        • + +
        • + {/*
        • */} - {/*
        • + {/*
        • Swap Keys
        • */} -
        • - Sign Out -
        • -
        -
        - )} - {hasUnprotectedKeys.value && ( -
      • - -
      • - )} - {!payPk.value && !ordPk.value && ( - <> - {showUnlockWalletButton.value && ( -
          - {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} -
        • -
          - Unlock Wallet - -
          -
        • -
        - )} -
          -
        • - - Create New Wallet - - -
        • -
        -
          -
        • - -
        • -
        - - )} -
      - )} - {showDepositModal.value && ( - { - showDepositModal.value = false; - }} - /> - )} - {showWithdrawalModal.value && ( - { - showWithdrawalModal.value = false; - }} - /> - )} - { - showUnlockWalletModal.value = false; - }} - onUnlock={() => { - showUnlockWalletModal.value = false; - }} - /> - - { - showImportWalletModal.value = false; - }} - /> - - { - showProtectKeysModal.value = false; - }} - /> - - -
    - ); +
  • + Sign Out +
  • +
+ + )} + {hasUnprotectedKeys.value && ( +
  • + +
  • + )} + {!payPk.value && !ordPk.value && ( + <> + {showUnlockWalletButton.value && ( +
      + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
    • +
      + Unlock Wallet + +
      +
    • +
    + )} +
      +
    • + + Create New Wallet + + +
    • +
    +
      +
    • + +
    • +
    + + )} + + )} + {showDepositModal.value && ( + { + showDepositModal.value = false; + }} + /> + )} + {showWithdrawalModal.value && ( + { + showWithdrawalModal.value = false; + }} + /> + )} + { + showUnlockWalletModal.value = false; + }} + onUnlock={() => { + showUnlockWalletModal.value = false; + }} + /> + { + showManageProfileModal.value = false; + }} + /> + + { + showImportWalletModal.value = false; + }} + /> + + { + showProtectKeysModal.value = false; + }} + /> + + + + ); }; export default WalletMenu; export const exportKeysViaFragment = () => { - // redirect to https://1sat.market/wallet/import#import= - const fk = localStorage.getItem("1satfk"); - const ok = localStorage.getItem("1satok"); - let data = "" - if (!fk || !ok) { - if (!payPk.value || !ordPk.value) { - toast.error("No keys to export. Encrypt your keys first."); - } - data = JSON.stringify({ payPk: payPk.value, ordPk: ordPk.value }); - } else { - data = JSON.stringify({ payPk: JSON.parse(fk), ordPk: JSON.parse(ok) }); - } - const b64 = btoa(data); - const base = "http://localhost:3000" // "https://1sat.market" - // window.location.href = `${base}/wallet/import#import=${b64}`; -} + // redirect to https://1sat.market/wallet/import#import= + const fk = localStorage.getItem("1satfk"); + const ok = localStorage.getItem("1satok"); + let data = ""; + if (!fk || !ok) { + if (!payPk.value || !ordPk.value) { + toast.error("No keys to export. Encrypt your keys first."); + } + data = JSON.stringify({ payPk: payPk.value, ordPk: ordPk.value }); + } else { + data = JSON.stringify({ payPk: JSON.parse(fk), ordPk: JSON.parse(ok) }); + } + const b64 = btoa(data); + const base = "http://localhost:3000"; // "https://1sat.market" + // window.location.href = `${base}/wallet/import#import=${b64}`; +}; export const swapKeys = () => { - // swaps paypk with ordpk values - const tempPayPk = payPk.value; - const tempOrdPk = ordPk.value; - if (!tempPayPk || !tempOrdPk) { - return; - } - ordPk.value = tempPayPk; - payPk.value = tempOrdPk; - toast.success("Keys Swapped"); + // swaps paypk with ordpk values + const tempPayPk = payPk.value; + const tempOrdPk = ordPk.value; + if (!tempPayPk || !tempOrdPk) { + return; + } + ordPk.value = tempPayPk; + payPk.value = tempOrdPk; + toast.success("Keys Swapped"); }; diff --git a/src/components/modal/deleteWallet/index.tsx b/src/components/modal/deleteWallet/index.tsx index 1f8af056..dc197396 100644 --- a/src/components/modal/deleteWallet/index.tsx +++ b/src/components/modal/deleteWallet/index.tsx @@ -1,5 +1,6 @@ "use client"; +import { removeIdentity } from "@/signals/bapIdentity/client"; import { bsv20Balances, bsv20Utxos, @@ -60,6 +61,8 @@ const DeleteWalletModal = ({ showUnlockWalletButton.value = false; createWalletStep.value = CreateWalletStep.Create; + + removeIdentity(); console.log("Cleared keys"); }, []); diff --git a/src/components/modal/importWallet/steps/fromMnemonic/EnterPassphraseStep.tsx b/src/components/modal/importWallet/steps/fromMnemonic/EnterPassphraseStep.tsx index 3236e1d1..8eaf68b5 100644 --- a/src/components/modal/importWallet/steps/fromMnemonic/EnterPassphraseStep.tsx +++ b/src/components/modal/importWallet/steps/fromMnemonic/EnterPassphraseStep.tsx @@ -5,9 +5,7 @@ import { } from "@/signals/wallet"; import { EncryptDecrypt } from "@/types/wallet"; -interface Props {} - -export function EnterPassphraseStep({}: Props) { +export function EnterPassphraseStep() { const onSubmit = () => { importWalletFromMnemonicStep.value = ImportWalletFromMnemonicStep.Done; }; diff --git a/src/components/modal/manageProfile/common/CancelButton.tsx b/src/components/modal/manageProfile/common/CancelButton.tsx new file mode 100644 index 00000000..a5944bfc --- /dev/null +++ b/src/components/modal/manageProfile/common/CancelButton.tsx @@ -0,0 +1,17 @@ +type Props = { handleCancel: Function }; + +const CancelButton = ({ handleCancel }: Props) => { + return ( + + ); +}; + +export default CancelButton; diff --git a/src/components/modal/manageProfile/index.tsx b/src/components/modal/manageProfile/index.tsx new file mode 100644 index 00000000..24d8267c --- /dev/null +++ b/src/components/modal/manageProfile/index.tsx @@ -0,0 +1,51 @@ +import { useSignals } from "@preact/signals-react/runtime"; +import SelectProfileJson from "./steps/SelectProfileJson"; +import ChooseIdentity from "./steps/ChooseIdentity"; +import EnterSelectedPassphrase from "./steps/EnterSelectedPassphrase"; +import Done from "./steps/Done"; + +import { + ImportProfileFromBackupJsonStep, + importProfileFromBackupJsonStep, +} from "@/signals/bapIdentity/index"; + + +interface ManageProfileModalProps { + open: boolean; + onClose: () => void; +} + +const ManageProfileModal = ({ open, onClose }: ManageProfileModalProps) => { + useSignals(); + + return ( + +
    +

    Manage Profile

    + + {importProfileFromBackupJsonStep.value === + ImportProfileFromBackupJsonStep.SelectFile && ( + + )} + {importProfileFromBackupJsonStep.value === + ImportProfileFromBackupJsonStep.ChooseIdentity && ( + + )} + {importProfileFromBackupJsonStep.value === + ImportProfileFromBackupJsonStep.EnterPassphrase && ( + + )} + {importProfileFromBackupJsonStep.value === + ImportProfileFromBackupJsonStep.Done && ( + + )} + +
    +
    + ); +}; + +export default ManageProfileModal; diff --git a/src/components/modal/manageProfile/steps/ChooseIdentity.tsx b/src/components/modal/manageProfile/steps/ChooseIdentity.tsx new file mode 100644 index 00000000..1ae3d71e --- /dev/null +++ b/src/components/modal/manageProfile/steps/ChooseIdentity.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { + bapIdentities, + identitiesLoading, + selectedBapIdentity, + ImportProfileFromBackupJsonStep, + importProfileFromBackupJsonStep, +} from "@/signals/bapIdentity/index"; + +import { useSignals } from "@preact/signals-react/runtime"; +import CancelButton from "../common/CancelButton"; + +import ProfileAccordion from "@/components/profileAccordion"; + +interface Props { + onClose: () => void; +} + +export default function ChooseIdentity({ onClose }: Props) { + useSignals(); + + const handleNext = () => { + importProfileFromBackupJsonStep.value = + ImportProfileFromBackupJsonStep.EnterPassphrase; + }; + + const handleCancel = () => { + selectedBapIdentity.value = null; + importProfileFromBackupJsonStep.value = + ImportProfileFromBackupJsonStep.SelectFile; + bapIdentities.value = null; + + onClose(); + }; + + return ( + <> + {identitiesLoading.value && ( +
    + +
    + )} + + +
    + + +
    + + ); +} diff --git a/src/components/modal/manageProfile/steps/Done.tsx b/src/components/modal/manageProfile/steps/Done.tsx new file mode 100644 index 00000000..ef61ef97 --- /dev/null +++ b/src/components/modal/manageProfile/steps/Done.tsx @@ -0,0 +1,34 @@ +import { + ImportProfileFromBackupJsonStep, + importProfileFromBackupJsonStep, + +} from "@/signals/bapIdentity"; + +interface Props { + onClose: () => void; +} + +export default function Done({ onClose }: Props) { + const onDone = () => { + importProfileFromBackupJsonStep.value = + ImportProfileFromBackupJsonStep.SelectFile; + onClose(); + }; + return ( + <> +

    + Your Identity has been successfully imported. +

    + +
    + +
    + + ); +} diff --git a/src/components/modal/manageProfile/steps/EnterSelectedPassphrase.tsx b/src/components/modal/manageProfile/steps/EnterSelectedPassphrase.tsx new file mode 100644 index 00000000..129ad462 --- /dev/null +++ b/src/components/modal/manageProfile/steps/EnterSelectedPassphrase.tsx @@ -0,0 +1,225 @@ +import { useState, useCallback } from "react"; +import { useSignals } from "@preact/signals-react/runtime"; + +import CancelButton from "../common/CancelButton"; +import { + ImportProfileFromBackupJsonStep, + importProfileFromBackupJsonStep, + bapIdentities, + selectedBapIdentity, + bapIdentityRaw, + bapIdEncryptionKey, + hasIdentityBackup, + activeBapIdentity, +} from "@/signals/bapIdentity"; +import { setIdentitySessionStorage } from "@/signals/bapIdentity/client"; +import { passphrase, payPk } from "@/signals/wallet"; +import { + encryptData, + generateEncryptionKeyFromPassphrase, +} from "@/utils/encryption"; +import { loadKeysFromEncryptedStorage } from "@/signals/wallet/client"; +import toast from "react-hot-toast"; +import type { EncryptedIdentityJson } from "@/types/identity"; +import { encryptionPrefix, toastErrorProps, toastProps } from "@/constants"; +import { PrivateKey } from "@bsv/sdk"; +import randomBytes from "randombytes"; + +interface Props { + onClose: () => void; +} +export default function EnterSelectedPassphrase({ onClose }: Props) { + useSignals(); + const [password, setPassword] = useState(); + const [error, setError] = useState(false); + + const handleCancel = () => { + cleanup(); + onClose(); + }; + + const cleanup = () => { + setError(false); + setPassword(undefined); + passphrase.value = ""; + bapIdentityRaw.value = null; + selectedBapIdentity.value = null; + bapIdentities.value = null; + + importProfileFromBackupJsonStep.value = + ImportProfileFromBackupJsonStep.SelectFile; + }; + + const onSubmit = () => { + passphrase.value = ""; + importProfileFromBackupJsonStep.value = + ImportProfileFromBackupJsonStep.Done; + }; + + const passwordCanDecrypt = useCallback(async () => { + if (!password) { + console.log("Missing decryption password"); + return; + } + try { + const succeeded = await loadKeysFromEncryptedStorage(password); + if (succeeded === "SUCCESS") { + return true; + } + return false; + } catch (e) { + console.error(e); + } + }, [password]); + + const handleEncryptProfile = useCallback(async () => { + if (!passphrase.value || !payPk.value) { + console.error("Missing passphrase or payPk"); + return; + } + console.log("passphrase", passphrase.value); + try { + const pubKey = PrivateKey.fromWif(payPk.value).toPublicKey().toString(); + bapIdEncryptionKey.value = + (await generateEncryptionKeyFromPassphrase(passphrase.value, pubKey)) ?? + null; + + if (!bapIdEncryptionKey.value) { + console.error("No encryption key found. Unable to encrypt."); + return; + } + const encrypted = await encryptData( + Buffer.from( + JSON.stringify({ + activeBapIdentity: selectedBapIdentity.value, + }), + "utf-8", + ), + bapIdEncryptionKey.value, + ); + const encryptedIdentity = `${encryptionPrefix}${encrypted}`; + + const encryptedIdentitiesBackup = await encryptData( + Buffer.from( + JSON.stringify({ + allBapIdentities: bapIdentities.value, + }), + "utf-8", + ), + bapIdEncryptionKey.value, + ); + + const encryptedAllIdentities = + `${encryptionPrefix}${encryptedIdentitiesBackup}`; + + const activeIdentityBackup: EncryptedIdentityJson = { + encryptedIdentity, + pubKey, + }; + + const allIdentitiesBackup = { + encryptedAllIdentities, + pubKey, + }; + + const encryptedIdentityString = JSON.stringify(activeIdentityBackup); + const encryptedAllIdentitiesString = JSON.stringify(allIdentitiesBackup); + + localStorage.setItem( + "encryptedAllIdentities", + encryptedAllIdentitiesString, + ); + + localStorage.setItem("encryptedIdentity", encryptedIdentityString); + + activeBapIdentity.value = selectedBapIdentity.value; + hasIdentityBackup.value = true; + if (!bapIdentities.value) { + console.error("No identities found"); + return false; + } + if (!selectedBapIdentity.value) { + console.error("No selected identity found"); + return false; + } + setIdentitySessionStorage(selectedBapIdentity.value); + setIdentitySessionStorage(bapIdentities.value); + return true; + } catch (e) { + console.log(e); + toast.error("Failed to encrypt identity", toastErrorProps); + } + }, [ + bapIdEncryptionKey.value, + bapIdentities.value, + passphrase.value, + payPk.value, + selectedBapIdentity.value, + ]); + + const handleDecryptEncrypt = useCallback(async () => { + const passwordCorrect = await passwordCanDecrypt(); + + if (passwordCorrect && password) { + passphrase.value = password; + const identityEncrypted = await handleEncryptProfile(); + if (identityEncrypted) { + onSubmit(); + } + } else { + setError(true); + } + setPassword(undefined); + }, [password, passwordCanDecrypt, handleEncryptProfile]); + + return ( + <> +
    + Enter your encryption password: + + {error && ( +

    + The password is incorrect. +

    + )} +
    +
    + + +
    + + ); +} diff --git a/src/components/modal/manageProfile/steps/SelectProfileJson.tsx b/src/components/modal/manageProfile/steps/SelectProfileJson.tsx new file mode 100644 index 00000000..cea3d6a8 --- /dev/null +++ b/src/components/modal/manageProfile/steps/SelectProfileJson.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { toastErrorProps } from "@/constants"; +import { + ImportProfileFromBackupJsonStep, + importProfileFromBackupJsonStep, + activeBapIdentity +} from "@/signals/bapIdentity/index"; +import { setBapIdentity, removeIdentity } from "@/signals/bapIdentity/client"; // set IDs, setMnemonic +import { useSignals, useSignalEffect} from "@preact/signals-react/runtime"; +import toast from "react-hot-toast"; +import { IoMdWarning } from "react-icons/io"; +import CancelButton from "../common/CancelButton"; + +interface Props { + onClose: () => void; +} + +export default function SelectProfileJson({ onClose }: Props) { + useSignals(); + + const handleCancel = () => { + onClose(); + importProfileFromBackupJsonStep.value = + ImportProfileFromBackupJsonStep.SelectFile; + }; + + const handleLogout = () => { + removeIdentity(); + onClose(); + }; + + const validateJson = (json: Record) => { + if (!json || typeof json !== "object") { + throw new Error("Invalid JSON"); + } + + if (!json.xprv || typeof json.xprv !== "string") { + throw new Error("Invalid JSON"); + } + + if ( + !json.ids || + !(typeof json.ids === "string" || typeof json.ids === "object") + ) { + throw new Error("Invalid JSON"); + } + }; + + const handleSelectFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result; + + if (typeof content !== "string") { + return; + } + + try { + const json = JSON.parse(content); + validateJson(json); + setBapIdentity(json); + handleNext(); + } catch (error) { + toast.error( + "Invalid JSON file. Please select a backup json.", + toastErrorProps + ); + } + }; + reader.readAsText(file); + }; + + const handleNext = () => { + importProfileFromBackupJsonStep.value = + ImportProfileFromBackupJsonStep.ChooseIdentity; + }; + + // TODO: Allow users to change identities without logging out + return ( + <> + {activeBapIdentity.value ? ( +

    + You already have an active Identity. If you wish to select + another one, please log out first. +

    + ) : ( + <> +

    + Select the backup JSON file you want to import +

    + +
    + +
    + + )} + +
    + {activeBapIdentity.value && ( + + )} + +
    + + ); +} diff --git a/src/components/pages/home/flowLoader.tsx b/src/components/pages/home/flowLoader.tsx index 877ebfed..c1aba63b 100644 --- a/src/components/pages/home/flowLoader.tsx +++ b/src/components/pages/home/flowLoader.tsx @@ -25,7 +25,7 @@ const FlowLoader = async ({ artifact }: { artifact?: OrdUtxo }) => { return ( ); }; diff --git a/src/components/pages/inscribe/html.tsx b/src/components/pages/inscribe/html.tsx index cbd7e130..e298b4c6 100644 --- a/src/components/pages/inscribe/html.tsx +++ b/src/components/pages/inscribe/html.tsx @@ -24,28 +24,13 @@ const InscribeHtml: React.FC = ({ inscribedCallback }) => { ); const changeText = useCallback( - async (e: any) => { + async (e: React.ChangeEvent) => { setText(e.target.value); }, + // eslint-disable-next-line react-hooks-signals/exhaustive-deps-signals [setText] ); - // useEffect(() => { - // const fire = async (t: string) => { - // // send base64 encoded preview html to server - // // https://ordfs.network/preview/ - // const encoded = toBase64(t); - // const previewUrl = `https://ordfs.network/preview/${encoded}`; - // const result = await fetch(previewUrl); - // const h = await result.text(); - // console.log("preview", { h }); - // setPreviewHtml(h); - // }; - // if (text) { - // fire(text); - // } - // }, [text]); - const submitDisabled = useMemo(() => { return inscribeStatus === FetchStatus.Loading; }, [inscribeStatus]); diff --git a/src/components/pages/profile/index.tsx b/src/components/pages/profile/index.tsx new file mode 100644 index 00000000..5ec678c5 --- /dev/null +++ b/src/components/pages/profile/index.tsx @@ -0,0 +1,49 @@ +"use client"; +import { Ubuntu, Ubuntu_Mono } from "next/font/google"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useSignals, useSignalEffect } from "@preact/signals-react/runtime"; +import { loadIdentityFromSessionStorage } from "@/signals/bapIdentity/client"; +import { + availableIdentities, + activeBapIdentity, +} from "@/signals/bapIdentity"; +import ProfileAccordion from "@/components/profileAccordion"; + +const ubuntu = Ubuntu({ + style: "normal", + weight: ["400", "700"], + subsets: ["latin"], +}); + +const ProfilePage = () => { + useSignals(); + const router = useRouter(); + + useEffect(() => { + if (!activeBapIdentity.value || !availableIdentities.value) { + const identities = loadIdentityFromSessionStorage(); + + if (!identities.availableIDs?.length) { + router.push("/"); + } + } + }, [activeBapIdentity.value, availableIdentities.value, router]); + + return availableIdentities.value ? ( +
    +
    +

    + Profile +

    + +
    +
    + ) : null; +}; +export default ProfilePage; diff --git a/src/components/profileAccordion/index.tsx b/src/components/profileAccordion/index.tsx new file mode 100644 index 00000000..3e2b3513 --- /dev/null +++ b/src/components/profileAccordion/index.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useSignals } from "@preact/signals-react/runtime"; +import Image from "next/image"; +import { MdAccountCircle } from "react-icons/md"; +import type { IdentityResult, Identity } from "@/types/identity"; +import { bapIdentities, bapIdentityRaw, selectedBapIdentity } from "@/signals/bapIdentity"; +import { getImageFromGP } from "@/utils/getImageFromGP"; +import { hashColor } from "@/utils/hashColor"; +import type { ReactNode } from "react"; +import { BAP } from "bsv-bap"; +import { HD } from "@bsv/sdk"; +import { identityPk } from "@/signals/wallet"; +import { setKeys } from "@/signals/wallet/client"; + +type Props = { + canSetActiveBapIdentity: boolean; + identities: IdentityResult[] | null; +}; + +const ProfileAccordion = ({ canSetActiveBapIdentity, identities }: Props) => { + useSignals(); + const hiddenFields = ["@context", "@type", "image", "logo"]; // not shown to user + + const handleClick = (idKey: string) => { + if (canSetActiveBapIdentity) { + setActiveBapIdentity(idKey); + } + return false; + }; + + const setActiveBapIdentity = (id: string) => { + const selectedIdentity = bapIdentities?.value?.find( + (identity: IdentityResult) => identity.idKey === id, + ); + selectedBapIdentity.value = selectedIdentity || null; + + const bapIdRaw = bapIdentityRaw.value; + if (!bapIdRaw) return; + const bapId = new BAP(bapIdRaw.xprv); + debugger; + bapId.importEncryptedIds(bapIdRaw.ids as string); + if (!selectedBapIdentity.value?.idKey) return; + const theBapId = bapId.getId(selectedBapIdentity.value?.idKey); + if (!theBapId) return; + const hdKey = HD.fromString(bapIdRaw.xprv).derive(theBapId?.currentPath); + const identityWif = hdKey.privKey?.toWif(); + console.log({theBapId, path: theBapId?.currentPath, identityWif}); + setKeys({identityPk: identityWif}); + }; + + const makeIdentityAvatar = (imageUrl: string | undefined, idKey: string) => { + const url = imageUrl && getImageFromGP(imageUrl); + + return ( +
    + {url ? ( + Profile Image + ) : ( + + )} +
    + ); + }; + + const processValue = (value: string | Record) => { + let processedValue: string | ReactNode = ""; + + if (typeof value === "object") { + const values = []; + for (const k in value) { + values.push(`${k}: ${value[k]}`); + } + processedValue = values.join(", "); + } else if ( + typeof value === "string" && + (value.startsWith("https://") || value.startsWith("http://")) + ) { + processedValue = ( + + {value} + + ); + } else if ( + typeof value === "string" && + (value.startsWith("b://") || value.startsWith("bitfs://")) + ) { + const imageUrl = getImageFromGP(value); + + processedValue = ( + + Click to view image + + ); + } else { + processedValue = value; + } + return {processedValue}; + }; + + const buildTable = (identity: Identity) => { + console.log({ identity }); + return ( + + + {Object.entries(identity) + .filter( + ([key, value]) => !hiddenFields.includes(key) && value !== "", + ) + .map(([key, value], index) => { + return ( + 0 ? "" : "bg-[#121212]"}`} + key={key} + > + + {processValue(value)} + + ); + })} + +
    {key}
    + ); + }; + + return identities?.length + ? identities.map((identity: IdentityResult) => ( + // biome-ignore lint/a11y/useKeyWithClickEvents: +
    handleClick(identity.idKey)} + > + {canSetActiveBapIdentity && } +
    + {makeIdentityAvatar( + identity?.identity?.image || identity?.identity?.logo, + identity?.idKey, + )} +
    +
    {identity?.identity?.alternateName}
    + {canSetActiveBapIdentity &&
    + {selectedBapIdentity.value?.idKey}
    } + {!canSetActiveBapIdentity && } +
    +
    +
    + {buildTable(identity?.identity)} +
    +
    + )) + : null; +}; + +export default ProfileAccordion; diff --git a/src/signals/bapIdentity/client.ts b/src/signals/bapIdentity/client.ts new file mode 100644 index 00000000..e08bced5 --- /dev/null +++ b/src/signals/bapIdentity/client.ts @@ -0,0 +1,260 @@ +"use client"; + +import { BAP } from "bsv-bap"; +import { encryptionPrefix } from "@/constants"; +import { + decryptData, + generateEncryptionKeyFromPassphrase, +} from "@/utils/encryption"; +import type { + ResultObj, + IdentityResult, + ProfileFromJson, + EncryptedIdentityJson, +} from "@/types/identity"; +import { + identitiesLoading, + bapIdentityRaw, + bapIdentities, + bapIdEncryptionKey, + activeBapIdentity, + selectedBapIdentity, + hasIdentityBackup, + availableIdentities, +} from "./index"; +import { HD } from "@bsv/sdk"; +import { identityPk } from "../wallet"; + +export const setBapIdentity = (importedProfile: ProfileFromJson) => { + identitiesLoading.value = true; + bapIdentityRaw.value = importedProfile; + extractIdentities(); +}; + +const getIdentityAddress = async (idKey: string) => { + const resp = fetch("/api/identity/get", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ idKey: idKey }), + }); + return (await resp).json(); +}; + +const getIdentityByAddress = async (resultObj: ResultObj) => { + const resp = fetch("/api/identity/validByAddress", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + address: resultObj?.result?.currentAddress, + }), + }); + return (await resp).json(); +}; + +export const extractIdentities = async () => { + if (!bapIdentityRaw.value) return; +debugger + const bapIdRaw = bapIdentityRaw.value; + const bapId = new BAP(bapIdRaw.xprv); + bapId.importEncryptedIds(bapIdRaw.ids as string); + // const path = bapId.lastIdPath; + // const key = HD.fromString(bapIdRaw.xprv).derive(path); + // identityPk.value = key.privKey.toWif(); + // const addressFromLastPath = key.privKey.toAddress(); + + const ids = bapId.listIds(); + if (!ids.length) { + identitiesLoading.value = false; + return; + } + + const resultsWithAddresses = await Promise.all( + ids.map((id: string) => getIdentityAddress(id)), + ); + if (!resultsWithAddresses.length) { + identitiesLoading.value = false; + return; + } + + const resultsWithIdentities = await Promise.all( + resultsWithAddresses.map((resultObj: ResultObj) => + getIdentityByAddress(resultObj), + ), + ); + + if (!resultsWithIdentities.length) { + identitiesLoading.value = false; + return; + } + + // TODO: check that the address from the last path is the same as the address from the identity + bapIdentities.value = resultsWithIdentities.filter((resultObj: ResultObj) => { + return resultObj.status === "OK"; + }).map((id) => id.result) as IdentityResult[]; + + console.log({bapIdentities: bapIdentities.value}) + identitiesLoading.value = false; +}; + +export const loadIdentityFromSessionStorage = () => { + const activeId = sessionStorage.getItem("activeIdentity"); + if (activeId) { + activeBapIdentity.value = JSON.parse(activeId); + } + + const availableId = sessionStorage.getItem("availableIdentities"); + if (availableId) { + availableIdentities.value = JSON.parse(availableId); + } + return { + activeId: activeBapIdentity.value, + availableIDs: availableIdentities.value, + }; +}; + +export const removeIdentity = () => { + bapIdEncryptionKey.value = null; + bapIdentityRaw.value = null; + bapIdentities.value = null; + identitiesLoading.value = false; + activeBapIdentity.value = null; + selectedBapIdentity.value = null; + hasIdentityBackup.value = false; + availableIdentities.value = null; + + localStorage.removeItem("encryptedIdentity"); + sessionStorage.removeItem("activeIdentity"); + localStorage.removeItem("encryptedAllIdentities"); + sessionStorage.removeItem("availableIdentities"); +}; + +export const loadAvailableIdentitiesFromEncryptedStorage = async ( + passphrase: string, +) => { + const allIdentitiesStr = localStorage.getItem("encryptedAllIdentities"); + if (!allIdentitiesStr) { + return false; + } + const encryptedIdentitiesParts = JSON.parse(allIdentitiesStr); + if ( + !encryptedIdentitiesParts.pubKey || + !encryptedIdentitiesParts.encryptedAllIdentities + ) { + throw new Error( + "Load identity error - No public key or encryptedIdentities props found in encrypted backup", + ); + } + + const encryptionKey = await generateEncryptionKeyFromPassphrase( + passphrase, + encryptedIdentitiesParts.pubKey, + ); + + if (!encryptionKey) { + throw new Error("No encryption key found. Unable to decrypt."); + } + + let decryptedBackupBin: Uint8Array; + + try { + decryptedBackupBin = await decryptData( + Buffer.from( + encryptedIdentitiesParts.encryptedAllIdentities.replace( + encryptionPrefix, + "", + ), + "base64", + ), + encryptionKey, + ); + } catch (error) { + console.log(error); + return false; + } + + const decryptedBackupStr = Buffer.from(decryptedBackupBin).toString("utf-8"); + + const { allBapIdentities: availableIdentitiesBackup } = + JSON.parse(decryptedBackupStr); + + availableIdentities.value = availableIdentitiesBackup; + setIdentitySessionStorage(availableIdentitiesBackup); + + return true; +}; + +export const loadIdentityFromEncryptedStorage = async (passphrase: string) => { + const encryptedIdentityStr = localStorage.getItem("encryptedIdentity"); + + if (!encryptedIdentityStr) { + return false; + } + const encryptedIdentityParts = JSON.parse( + encryptedIdentityStr, + ) as EncryptedIdentityJson; + + if ( + !encryptedIdentityParts.pubKey || + !encryptedIdentityParts.encryptedIdentity + ) { + throw new Error( + "Load identity error - No public key or encryptedIdentity props found in encrypted backup", + ); + } + + const encryptionKey = await generateEncryptionKeyFromPassphrase( + passphrase, + encryptedIdentityParts.pubKey, + ); + + if (!encryptionKey) { + throw new Error("No encryption key found. Unable to decrypt."); + } + + let decryptedBackupBin: Uint8Array; + + try { + decryptedBackupBin = await decryptData( + Buffer.from( + encryptedIdentityParts.encryptedIdentity.replace(encryptionPrefix, ""), + "base64", + ), + encryptionKey, + ); + } catch (error) { + console.log(error); + return false; + } + + const decryptedBackupStr = Buffer.from(decryptedBackupBin).toString("utf-8"); + + const { activeBapIdentity: activeIdentityBackup } = + JSON.parse(decryptedBackupStr); + + if (!activeIdentityBackup?.identity) { + return false; + } + + activeBapIdentity.value = activeIdentityBackup; + setIdentitySessionStorage(activeIdentityBackup); + + return true; +}; + +export const setIdentitySessionStorage = ( + identity: IdentityResult | IdentityResult[], +) => { + console.log({ identity }); + if (!identity) return; + if (Array.isArray(identity)) { + const availablIdentitiesString = JSON.stringify(identity); + sessionStorage.setItem("availableIdentities", availablIdentitiesString); + } else { + const activeIdentityString = JSON.stringify(identity); + sessionStorage.setItem("activeIdentity", activeIdentityString); + } +}; diff --git a/src/signals/bapIdentity/index.ts b/src/signals/bapIdentity/index.ts new file mode 100644 index 00000000..5b5d8a46 --- /dev/null +++ b/src/signals/bapIdentity/index.ts @@ -0,0 +1,27 @@ +import { signal } from "@preact/signals-react"; + +import type { + IdentityResult, + ProfileFromJson, +} from "@/types/identity"; + +export enum ImportProfileFromBackupJsonStep { + SelectFile = 1, + ChooseIdentity = 2, + EnterPassphrase = 3, + Done = 4, +} + +// signals +export const importProfileFromBackupJsonStep = + signal( + ImportProfileFromBackupJsonStep.SelectFile + ); +export const bapIdEncryptionKey = signal(null); +export const bapIdentityRaw = signal(null); +export const bapIdentities = signal(null); +export const identitiesLoading = signal(false); +export const activeBapIdentity = signal(null); +export const selectedBapIdentity = signal(null); +export const hasIdentityBackup = signal(false); +export const availableIdentities = signal(null); diff --git a/src/signals/wallet/client.tsx b/src/signals/wallet/client.tsx index 839c13c7..dc3a79b6 100644 --- a/src/signals/wallet/client.tsx +++ b/src/signals/wallet/client.tsx @@ -2,159 +2,178 @@ import { encryptionPrefix } from "@/constants"; import type { PendingTransaction } from "@/types/preview"; -import { - CreateWalletStep, - type Keys, - type DecryptedBackupJson, - type EncryptedBackupJson, +import type { + Keys, + DecryptedBackupJson, + EncryptedBackupJson, } from "@/types/wallet"; +import { removeIdentity } from "@/signals/bapIdentity/client"; import { - decryptData, - generateEncryptionKeyFromPassphrase, + decryptData, + generateEncryptionKeyFromPassphrase, } from "@/utils/encryption"; import { - bsv20Balances, - bsv20Utxos, - changeAddressPath, - createWalletStep, - encryptedBackup, - encryptionKey, - identityAddressPath, - identityPk, - mnemonic, - ordAddressPath, - ordPk, - passphrase, - payPk, - pendingTxs, - showUnlockWalletButton, - utxos, + changeAddressPath, + identityAddressPath, + identityPk, + mnemonic, + ordAddressPath, + ordPk, + payPk, + pendingTxs, + utxos, } from "."; export const setPendingTxs = (txs: PendingTransaction[]) => { - pendingTxs.value = [...txs]; - localStorage.setItem("1satpt", JSON.stringify(txs)); + pendingTxs.value = [...txs]; + localStorage.setItem("1satpt", JSON.stringify(txs)); }; export const loadKeysFromBackupFiles = (backupFile: File): Promise => { - return new Promise((resolve, reject) => { - if (!backupFile) { - return reject(); - } - const f = new FileReader(); - - f.onload = (e) => { - // get the creation date of the file - //const lastModified = new Date(backupFile.lastModified); - - //const badDateStart = new Date("2021-03-01T00:00:00.000Z"); - //const badDateEnd = new Date("2021-03-05T00:00:00.000Z"); - - // check if the file was modified in the bad range - // if (lastModified > badDateStart && lastModified < badDateEnd) { - // console.log("Invalid backup file based on creation date", lastModified); - // toast.error( - // "Invalid backup file. Please visit our discord here https://discord.gg/D6HKMKUpmV.", - // { - // duration: 999999999, - // }, - // ); - // } - const backup = JSON.parse(e.target?.result as string) as { - payPk: string; - ordPk: string; - }; - // console.log({ backup }); - - setPendingTxs([]); - utxos.value = null; - payPk.value = backup.payPk; - ordPk.value = backup.ordPk; - return resolve(); - }; - f.onerror = (e) => { - console.error(e); - return reject(e); - }; - f.readAsText(backupFile); - }); + return new Promise((resolve, reject) => { + if (!backupFile) { + return reject(); + } + const f = new FileReader(); + + f.onload = (e) => { + // get the creation date of the file + //const lastModified = new Date(backupFile.lastModified); + + //const badDateStart = new Date("2021-03-01T00:00:00.000Z"); + //const badDateEnd = new Date("2021-03-05T00:00:00.000Z"); + + // check if the file was modified in the bad range + // if (lastModified > badDateStart && lastModified < badDateEnd) { + // console.log("Invalid backup file based on creation date", lastModified); + // toast.error( + // "Invalid backup file. Please visit our discord here https://discord.gg/D6HKMKUpmV.", + // { + // duration: 999999999, + // }, + // ); + // } + const backup = JSON.parse(e.target?.result as string) as { + payPk: string; + ordPk: string; + }; + // console.log({ backup }); + + setPendingTxs([]); + utxos.value = null; + payPk.value = backup.payPk; + ordPk.value = backup.ordPk; + return resolve(); + }; + f.onerror = (e) => { + console.error(e); + return reject(e); + }; + f.readAsText(backupFile); + }); }; - -export const setKeys = (keys: Keys) => { - payPk.value = keys.payPk; - ordPk.value = keys.ordPk; - mnemonic.value = keys.mnemonic ?? null; - changeAddressPath.value = keys.changeAddressPath ?? null; - ordAddressPath.value = keys.ordAddressPath ?? null; - identityPk.value = keys.identityPk ?? null; - identityAddressPath.value = keys.identityAddressPath ?? null; - - sessionStorage.setItem("1satfk", keys.payPk); - sessionStorage.setItem("1satok", keys.ordPk); +export const setKeys = (keys: Partial) => { + if (keys.payPk) { + payPk.value = keys.payPk; + sessionStorage.setItem("1satfk", keys.payPk); + } + if (keys.ordPk) { + ordPk.value = keys.ordPk; + sessionStorage.setItem("1satok", keys.ordPk); + } + if (keys.mnemonic) { + mnemonic.value = keys.mnemonic; + } + if (keys.changeAddressPath) { + changeAddressPath.value = keys.changeAddressPath; + } + if (keys.ordAddressPath) { + ordAddressPath.value = keys.ordAddressPath; + } + if (keys.identityPk) { + identityPk.value = keys.identityPk; + sessionStorage.setItem("1satid", keys.identityPk); + } + if (keys.identityAddressPath) { + identityAddressPath.value = keys.identityAddressPath; + } }; export const loadKeysFromSessionStorage = () => { - const payPk = sessionStorage.getItem("1satfk"); - const ordPk = sessionStorage.getItem("1satok"); + const payPk = sessionStorage.getItem("1satfk") || undefined; + const ordPk = sessionStorage.getItem("1satok") || undefined; + const idKey = sessionStorage.getItem("1satid") || undefined; - if (payPk && ordPk) { - setKeys({ payPk, ordPk }); - } + setKeys({ payPk, ordPk, identityPk: idKey }); }; export const loadKeysFromEncryptedStorage = async (passphrase: string) => { - const encryptedKeysStr = localStorage.getItem("encryptedBackup"); - - if (!encryptedKeysStr) { - return; - } - - const encryptedKeys = JSON.parse(encryptedKeysStr) as EncryptedBackupJson; - - if (!encryptedKeys.pubKey || !encryptedKeys.encryptedBackup) { - throw new Error( - "Load keys error - No public key or encryptedBackup props found in encrypted backup" - ); - } - - const encryptionKey = await generateEncryptionKeyFromPassphrase( - passphrase, - encryptedKeys.pubKey - ); - - if (!encryptionKey) { - throw new Error("No encryption key found. Unable to decrypt."); - } - - const decryptedBackupBin = await decryptData( - Buffer.from( - encryptedKeys.encryptedBackup.replace(encryptionPrefix, ""), - "base64" - ), - encryptionKey - ); - - const decryptedBackupStr = - Buffer.from(decryptedBackupBin).toString("utf-8"); - - const decryptedBackup = JSON.parse( - decryptedBackupStr - ) as DecryptedBackupJson; - - if (!decryptedBackup.payPk || !decryptedBackup.ordPk) { - throw new Error( - "Load keys error - No payPk or ordPk props found in decrypted backup" - ); - } - - setKeys({ - payPk: decryptedBackup.payPk, - ordPk: decryptedBackup.ordPk, - mnemonic: decryptedBackup.mnemonic, - changeAddressPath: decryptedBackup.payDerivationPath, - ordAddressPath: decryptedBackup.ordDerivationPath, - ...(decryptedBackup.identityPk !== undefined && { identityPk: decryptedBackup.identityPk }), - ...(decryptedBackup.identityDerivationPath !== undefined && { identityAddressPath: decryptedBackup.identityDerivationPath }), - }); + const encryptedKeysStr = localStorage.getItem("encryptedBackup"); + + if (!encryptedKeysStr) { + return; + } + + const encryptedKeys = JSON.parse(encryptedKeysStr) as EncryptedBackupJson; + + if (!encryptedKeys.pubKey || !encryptedKeys.encryptedBackup) { + throw new Error( + "Load keys error - No public key or encryptedBackup props found in encrypted backup", + ); + } + + const encryptionKey = await generateEncryptionKeyFromPassphrase( + passphrase, + encryptedKeys.pubKey, + ); + + if (!encryptionKey) { + throw new Error("No encryption key found. Unable to decrypt."); + } + + let decryptedBackupBin: Uint8Array; + + if (!encryptedKeys.encryptedBackup.startsWith(encryptionPrefix)) { + throw new Error("Invalid encryption prefix"); + } + + try { + decryptedBackupBin = await decryptData( + Buffer.from( + encryptedKeys.encryptedBackup.replace(encryptionPrefix, ""), + "base64", + ), + encryptionKey, + ); + } catch (error) { + console.log(error); + return "FAILED"; + } + + const decryptedBackupStr = Buffer.from(decryptedBackupBin).toString("utf-8"); + + const decryptedBackup = JSON.parse(decryptedBackupStr) as DecryptedBackupJson; + + if (!decryptedBackup.payPk || !decryptedBackup.ordPk) { + throw new Error( + "Load keys error - No payPk or ordPk props found in decrypted backup", + ); + } + + setKeys({ + payPk: decryptedBackup.payPk, + ordPk: decryptedBackup.ordPk, + mnemonic: decryptedBackup.mnemonic, + changeAddressPath: decryptedBackup.payDerivationPath, + ordAddressPath: decryptedBackup.ordDerivationPath, + ...(decryptedBackup.identityPk !== undefined && { + identityPk: decryptedBackup.identityPk, + }), + ...(decryptedBackup.identityDerivationPath !== undefined && { + identityAddressPath: decryptedBackup.identityDerivationPath, + }), + }); + + return "SUCCESS"; }; diff --git a/src/signals/wallet/index.tsx b/src/signals/wallet/index.tsx index 3c55ffbf..fe10c5e1 100644 --- a/src/signals/wallet/index.tsx +++ b/src/signals/wallet/index.tsx @@ -35,6 +35,8 @@ export const showEnterPassphrase = signal(null); export const encryptedBackup = signal(null); export const encryptionKey = signal(null); export const passphrase = signal(""); +export const passphraseVerified = signal(false); +export const passphraseCheckInProgress = signal(false); export const migrating = signal(false); /** * Unlock Wallet @@ -42,7 +44,6 @@ export const migrating = signal(false); export const showUnlockWalletModal = signal(false); export const showUnlockWalletButton = signal(false); - /** * Wallet keys */ @@ -75,24 +76,25 @@ export const showDepositModal = signal(false); * Import Wallet */ export enum ImportWalletTab { - FromBackupJson = 0, - FromMnemonic = 1, - FromFragment = 2, + FromBackupJson = 0, + FromMnemonic = 1, + FromFragment = 2, } export enum ImportWalletFromBackupJsonStep { - SelectFile = 0, - EnterPassphrase = 1, - Done = 2, + SelectFile = 0, + EnterPassphrase = 1, + Done = 2, } export enum ImportWalletFromMnemonicStep { - EnterMnemonic = 0, - GenerateWallet = 1, - EnterPassphrase = 2, - Done = 3, + EnterMnemonic = 0, + GenerateWallet = 1, + EnterPassphrase = 2, + Done = 3, } + export const importWalletTab = signal(null); export const importWalletFromBackupJsonStep = signal( diff --git a/src/types/identity.ts b/src/types/identity.ts new file mode 100644 index 00000000..e2971444 --- /dev/null +++ b/src/types/identity.ts @@ -0,0 +1,62 @@ +interface Place { + name?: string; + "@type": "Place"; +} + +export interface IdentityResult { + identity: Identity; + addresses: Address[]; + block: number; + currentAddress: string; + firstSeen: number; + rootAddress: string; + timestamp: number; + valid: boolean; + idKey: string; +} + +export interface Identity { + "@context"?: string; + "@type"?: string; + alternateName?: string; + description?: string; + homeLocation?: Place; + url?: string; + banner?: string; + logo?: string; + image?: string; + email?: string; + paymail?: string; + address?: string; + bitcoinAddress?: string; +} + +export interface Address { + block: number; + address: string; + txId: string; +} + +export interface IdentityAddressResult { + idKey: string; + addresses: Address[]; + firstSeen: number; + currentAddress: string; + rootAddress: string; + timestamp: number; +} + +export interface ResultObj { + status: string; + result: IdentityAddressResult | IdentityResult; +} + +export type ProfileFromJson = { + xprv: string; + ids: string | string[]; +}; + +export interface EncryptedIdentityJson { + encryptedIdentity?: string; + pubKey?: string; +} diff --git a/src/types/wallet.ts b/src/types/wallet.ts index 5d82fb4c..4364ca15 100644 --- a/src/types/wallet.ts +++ b/src/types/wallet.ts @@ -6,8 +6,6 @@ export enum EncryptDecrypt { export interface EncryptedBackupJson { encryptedBackup?: string; pubKey?: string; - // fundingChildKey: number; - // ordChildKey: number; } export interface OldDecryptedBackupJson { diff --git a/src/utils/address.ts b/src/utils/address.ts index 3698128c..fcde74f3 100644 --- a/src/utils/address.ts +++ b/src/utils/address.ts @@ -104,9 +104,7 @@ export const getUtxos = async (address: string): Promise => { export const getOutpoints = async (ids: string[], script: boolean) => { const url = `${API_HOST}/api/txos/outpoints?script=${script}`; - console.log("almost", url, "with", ids); const uniqueIds = uniq(ids); - console.log("hitting", url, "with", uniqueIds); const res = await fetch(url, { method: "POST", diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts index 557ef61c..cd3acadc 100644 --- a/src/utils/encryption.ts +++ b/src/utils/encryption.ts @@ -1,3 +1,5 @@ +import randomBytes from "randombytes"; + const getKey = async (key: Uint8Array): Promise => { return await crypto.subtle.importKey( "raw", @@ -11,8 +13,8 @@ const getKey = async (key: Uint8Array): Promise => { export const encryptData = async ( data: Uint8Array, key: Uint8Array, - iv: Uint8Array -): Promise => { +): Promise => { + const iv = new Uint8Array(randomBytes(16).buffer); const cryptoKey = await getKey(key); const encryptedContent = await crypto.subtle.encrypt( { name: "AES-CBC", iv }, @@ -20,21 +22,15 @@ export const encryptData = async ( data ); - // Combine IV and encrypted content - // const result = new Uint8Array(iv.length + encryptedContent.byteLength); - // result.set(iv); - // result.set(new Uint8Array(encryptedContent), iv.length); - - // They are already combined - return new Uint8Array(encryptedContent); + return Buffer.concat([iv, new Uint8Array(encryptedContent)]).toString("base64"); } export const decryptData = async( encryptedData: Uint8Array, key: Uint8Array ): Promise => { - const cryptoKey = await getKey(key); const iv = encryptedData.slice(0, 16); + const cryptoKey = await getKey(key); const encryptedContent = encryptedData.slice(16); const decryptedContent = await crypto.subtle.decrypt( @@ -89,6 +85,5 @@ export const generateEncryptionKeyFromPassphrase = async ( } const pubKeyBytes = new Uint8Array(Buffer.from(pubKey, "base64").buffer); - return await generateEncryptionKey(passphrase, pubKeyBytes); } \ No newline at end of file diff --git a/src/utils/getImageFromGP.ts b/src/utils/getImageFromGP.ts new file mode 100644 index 00000000..83c035ea --- /dev/null +++ b/src/utils/getImageFromGP.ts @@ -0,0 +1,13 @@ +export function getImageFromGP(imageTxId: string) { + if (imageTxId.startsWith("bitfs://")) { +// bitfs://a53276421d2063a330ebbf003ab5b8d453d81781c6c8440e2df83368862082c5.out.1.1 + return `https://ordfs.network/${imageTxId.replace( + "bitfs://", + "" + ).replace(".out.", "_").slice(0, -2)}`; + } + return `https://ordfs.network/${imageTxId.replace( + "b://", + "" + )}`; +} diff --git a/src/utils/hashColor.ts b/src/utils/hashColor.ts new file mode 100644 index 00000000..2c658ec5 --- /dev/null +++ b/src/utils/hashColor.ts @@ -0,0 +1,7 @@ +import ColorHash from "color-hash"; +const colorHash = new ColorHash(); + +export function hashColor(id: string) { + const color = colorHash.rgb(id); + return color.toString(); +} diff --git a/src/utils/inscribe.ts b/src/utils/inscribe.ts index 8e6ca20d..c1ee60ec 100644 --- a/src/utils/inscribe.ts +++ b/src/utils/inscribe.ts @@ -1,7 +1,7 @@ "use client"; import { toastErrorProps } from "@/constants"; -import { payPk, pendingTxs } from "@/signals/wallet"; +import { identityPk, payPk, pendingTxs } from "@/signals/wallet"; import { fundingAddress, ordAddress } from "@/signals/wallet/address"; import type { PendingTransaction } from "@/types/preview"; import { @@ -14,11 +14,13 @@ import { type Payment, type RemoteSigner, type Utxo, + type LocalSigner, } from "js-1sat-ord"; import toast from "react-hot-toast"; import { readFileAsBase64 } from "./file"; import { setPendingTxs } from "@/signals/wallet/client"; import { PrivateKey } from "@bsv/sdk"; +import { activeBapIdentity, bapIdentities } from "@/signals/bapIdentity"; export const handleInscribing = async ( payPk: string, @@ -41,38 +43,46 @@ export const handleInscribing = async ( // "L1tFiewYRivZciv146HnCPBWzV35BR65dsJWZBYkQsKJ8UhXLz6q" // ); console.log("Inscribing with", { metaData }); - const signer = { - // idKey // optional id key - keyHost: "http://localhost:21000", - } as RemoteSigner; const destinations: Destination[] = [ { address: ordAddress, - inscription + inscription, }, ]; + // [fundingUtxo], + // ordAddress, + // paymentPk, + // changeAddress, + // 0.05, + // [inscription], + // metadata, // optional metadata + // undefined, + // payments, - // [fundingUtxo], - // ordAddress, - // paymentPk, - // changeAddress, - // 0.05, - // [inscription], - // metadata, // optional metadata - // undefined, - // payments, + let signer: undefined | LocalSigner; + if (identityPk.value) { + signer = { + idKey: PrivateKey.fromWif(identityPk.value), + }; + } + // TODO: TokenPass support + // const signer = { + // // idKey // optional id key + // keyHost: "http://localhost:21000", + // } as RemoteSigner; const config: CreateOrdinalsConfig = { utxos, destinations, paymentPk, - metaData, - additionalPayments, + metaData, + additionalPayments, + signer, }; const { spentOutpoints, tx, payChange } = await createOrdinals(config); - return { spentOutpoints, tx, payChange}; + return { spentOutpoints, tx, payChange }; }; // same as haleInscribing but takes multiple utxos and multiple inscriptions @@ -91,20 +101,28 @@ export const handleBulkInscribing = async ( // keyHost: "http://localhost:21000", // } as RemoteSigner; - const destinations: Destination[] = inscriptions.map((inscription) => { - return { - address: ordAddress, - inscription - } - }); + let signer = undefined; + if (activeBapIdentity.value && identityPk.value) { + signer = { + idKey: PrivateKey.fromWif(identityPk.value), + }; + } + + const destinations: Destination[] = inscriptions.map((inscription) => { + return { + address: ordAddress, + inscription, + }; + }); const config: CreateOrdinalsConfig = { utxos, destinations, paymentPk, - metaData, - additionalPayments, - changeAddress, + metaData, + additionalPayments, + changeAddress, + signer, }; const { tx, spentOutpoints, payChange } = await createOrdinals(config); @@ -122,24 +140,31 @@ export const handleBulkInscribingWithData = async ( ) => { const paymentPk = PrivateKey.fromWif(payPk); - const signer = { - keyHost: "http://localhost:21000", - } as RemoteSigner; + // const signer = { + // keyHost: "http://localhost:21000", + // } as RemoteSigner; + let signer: undefined | LocalSigner; + if (identityPk.value) { + signer = { + idKey: PrivateKey.fromWif(identityPk.value), + }; + } + + const config: CreateOrdinalsConfig = { + utxos: fundingUtxos, + destinations: inscriptions.map((inscription) => { + return { + address: ordAddress, + inscription, + }; + }), + paymentPk, + metaData: metadata, + additionalPayments: payments, + changeAddress, + signer, + }; - const config: CreateOrdinalsConfig = { - utxos: fundingUtxos, - destinations: inscriptions.map((inscription) => { - return { - address: ordAddress, - inscription - } - }), - paymentPk, - metaData: metadata, - additionalPayments: payments, - changeAddress, - }; - const { tx, spentOutpoints } = await createOrdinals(config); // fundingUtxos, // ordAddress, @@ -163,9 +188,9 @@ export const inscribeFile = async ( if (!file?.type || !utxos.length) { throw new Error("File or utxo not provided"); } - if (!payPk.value || !ordAddress.value) { - throw new Error("Missing payPk or ordAddress"); - } + if (!payPk.value || !ordAddress.value) { + throw new Error("Missing payPk or ordAddress"); + } // setInscribeStatus(FetchStatus.Loading); try { @@ -182,23 +207,22 @@ export const inscribeFile = async ( metadata, ); - const result = { - rawTx: tx.toHex(), - size: tx.toBinary().length, - fee: tx.getFee(), - numInputs: tx.inputs.length, - numOutputs: tx.outputs.length, - txid: tx.id('hex'), - spentOutpoints, - payChange, - metadata, - } as PendingTransaction; - console.log(Object.keys(result)); + const result = { + rawTx: tx.toHex(), + size: tx.toBinary().length, + fee: tx.getFee(), + numInputs: tx.inputs.length, + numOutputs: tx.outputs.length, + txid: tx.id("hex"), + spentOutpoints, + payChange, + metadata, + } as PendingTransaction; + console.log(Object.keys(result)); - setPendingTxs([result]); - //setInscribeStatus(FetchStatus.Success); - return result; - + setPendingTxs([result]); + //setInscribeStatus(FetchStatus.Success); + return result; } catch (e) { console.error(e); //setInscribeStatus(FetchStatus.Error); @@ -219,10 +243,9 @@ export const inscribeUtf8 = async ( iterations = 1, payments: Payment[] = [], ) => { - - if (!payPk.value || !ordAddress.value || !fundingAddress.value) { - throw new Error("Missing payPk, ordAddress or fundingAddress"); - } + if (!payPk.value || !ordAddress.value || !fundingAddress.value) { + throw new Error("Missing payPk, ordAddress or fundingAddress"); + } const fileAsBase64 = Buffer.from(text).toString("base64"); // normalize utxo to array @@ -242,18 +265,16 @@ export const inscribeUtf8 = async ( undefined, payments, ); - const satsIn = utxos.reduce((acc, utxo) => acc + utxo.satoshis, 0); - const satsOut = tx.outputs.reduce((acc, output) => acc + (output.satoshis || 0), 0); - const fee = satsIn - satsOut; + const result = { rawTx: tx.toHex(), size: tx.toBinary().length, - fee, + fee: tx.getFee(), numInputs: tx.inputs.length, numOutputs: tx.outputs.length, - txid: tx.id('hex'), + txid: tx.id("hex"), spentOutpoints, - payChange, + payChange, iterations, } as PendingTransaction; setPendingTxs([result]); diff --git a/src/utils/uint8array.ts b/src/utils/uint8array.ts index 269f6d4c..e8fce843 100644 --- a/src/utils/uint8array.ts +++ b/src/utils/uint8array.ts @@ -1,6 +1,6 @@ export function fromHexString(hexString: string): Uint8Array { return new Uint8Array( - hexString.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)) + hexString.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)) ); }