import {describe, it, expect, vi, afterEach} from "vitest"; import algosdk from "algosdk"; import {sign_detached} from "tweetnacl-ts"; import PeraWalletConnect from "../PeraWalletConnect"; import {ScopeType} from "../util/model/peraWalletModels"; import { saveWalletDetailsToStorage, resetWalletDetailsFromStorage } from "../util/storage/storageUtils"; vi.mock("../util/api/peraWalletConnectApi", () => ({ getPeraConnectConfig: () => Promise.resolve({ isWebWalletAvailable: false, bridgeURL: "https://bridge.test", webWalletURL: "https://web.test", shouldDisplayNewBadge: false, shouldUseSound: false, silent: true, promoteMobile: false }) })); const account = algosdk.generateAccount(); describe("PeraWalletConnect.signData", () => { afterEach(() => { resetWalletDetailsFromStorage(); vi.restoreAllMocks(); }); it("skips verification by default", async () => { saveWalletDetailsToStorage([account.addr.toString()], "pera-wallet"); const pera = new PeraWalletConnect(); const signature = new Uint8Array([1, 2, 3]); vi.spyOn(pera as any, "getTransport").mockReturnValue({ signData: vi.fn().mockResolvedValue([signature]) }); const authAddrSpy = vi.spyOn(pera as any, "getAccountAuthAddr"); const result = await pera.signData( [{data: new Uint8Array([9]), message: "m"}], account.addr.toString() ); expect(result).toEqual([signature]); expect(authAddrSpy).not.toHaveBeenCalled(); }); it("resolves when verifySignature is true and the signature checks out", async () => { saveWalletDetailsToStorage([account.addr.toString()], "pera-wallet"); const pera = new PeraWalletConnect(); const data = new Uint8Array([9, 9]); vi.spyOn(pera as any, "getAccountAuthAddr").mockResolvedValue(null); vi.spyOn(pera, "verifySignature").mockReturnValue(true); vi.spyOn(pera as any, "getTransport").mockReturnValue({ signData: vi.fn().mockResolvedValue([new Uint8Array([1])]) }); await expect( pera.signData([{data, message: "m"}], account.addr.toString(), true) ).resolves.toEqual([new Uint8Array([1])]); }); it("throws SIGN_DATA_VERIFICATION_FAILED when the signature does not check out", async () => { saveWalletDetailsToStorage([account.addr.toString()], "pera-wallet"); const pera = new PeraWalletConnect(); vi.spyOn(pera as any, "getAccountAuthAddr").mockResolvedValue(null); vi.spyOn(pera, "verifySignature").mockReturnValue(false); vi.spyOn(pera as any, "getTransport").mockReturnValue({ signData: vi.fn().mockResolvedValue([new Uint8Array([1])]) }); await expect( pera.signData( [{data: new Uint8Array([9]), message: "m"}], account.addr.toString(), true ) ).rejects.toMatchObject({data: {type: "SIGN_DATA_VERIFICATION_FAILED"}}); }); it("falls back to the on-chain auth address as the effective signer when verifying", async () => { saveWalletDetailsToStorage([account.addr.toString()], "pera-wallet"); const pera = new PeraWalletConnect(); const authAddr = algosdk.generateAccount().addr.toString(); vi.spyOn(pera as any, "getAccountAuthAddr").mockResolvedValue(authAddr); const verifySpy = vi.spyOn(pera, "verifySignature").mockReturnValue(true); vi.spyOn(pera as any, "getTransport").mockReturnValue({ signData: vi.fn().mockResolvedValue([new Uint8Array([1])]) }); await pera.signData( [{data: new Uint8Array([9]), message: "m"}], account.addr.toString(), true ); expect(verifySpy).toHaveBeenCalledWith( expect.any(Uint8Array), expect.any(Uint8Array), authAddr ); }); }); describe("PeraWalletConnect.getAccountAuthAddr (legacy signData verification)", () => { afterEach(() => { vi.restoreAllMocks(); }); function mockAlgod(pera: PeraWalletConnect) { return vi.spyOn(pera as any, "getAlgodClient").mockReturnValue({ client: { accountInformation: () => ({do: () => Promise.resolve({authAddr: undefined})}) } }); } it("keeps reading mainnet for all-networks, unset and betanet chain ids", async () => { // The legacy path predates per-network sessions; its mainnet fallback is a // compatibility guarantee that getNetworkFromChainId no longer provides. for (const chainId of [4160, 416003, undefined]) { const pera = new PeraWalletConnect(); const spy = mockAlgod(pera); await (pera as any).getAccountAuthAddr(account.addr.toString(), chainId); expect(spy).toHaveBeenCalledWith("mainnet"); } }); it("reads testnet for the testnet chain id", async () => { const pera = new PeraWalletConnect(); const spy = mockAlgod(pera); await (pera as any).getAccountAuthAddr(account.addr.toString(), 416002); expect(spy).toHaveBeenCalledWith("testnet"); }); }); describe("PeraWalletConnect.signArc60Data", () => { afterEach(() => { resetWalletDetailsFromStorage(); vi.restoreAllMocks(); }); function makePayload() { return { data: Buffer.from(new Uint8Array([1, 2])).toString("base64"), signer: algosdk.decodeAddress(account.addr.toString()).publicKey, domain: window.location.origin, authenticatorData: new Uint8Array(37) }; } it("throws when connected to neither mobile nor the extension", async () => { const pera = new PeraWalletConnect(); await expect( pera.signArc60Data(makePayload(), {scope: ScopeType.AUTH, encoding: "base64"}) ).rejects.toThrow( "ARC-60 signing is only supported via the Pera mobile wallet or the Pera extension." ); }); it("resolves with the transport's response when verification is skipped", async () => { saveWalletDetailsToStorage([account.addr.toString()], "pera-wallet"); const pera = new PeraWalletConnect(); const signature = new Uint8Array([9, 9]); vi.spyOn(pera as any, "getTransport").mockReturnValue({ signArc60Data: vi.fn().mockResolvedValue({signature}) }); const response = await pera.signArc60Data(makePayload(), { scope: ScopeType.AUTH, encoding: "base64" }); expect(response.signature).toBe(signature); expect(response.signer).toEqual( algosdk.decodeAddress(account.addr.toString()).publicKey ); }); it("resolves when verifySignature is true and the ARC-60 signature checks out", async () => { saveWalletDetailsToStorage([account.addr.toString()], "pera-wallet"); const pera = new PeraWalletConnect(); const signature = new Uint8Array([9, 9]); vi.spyOn(pera as any, "getTransport").mockReturnValue({ signArc60Data: vi.fn().mockResolvedValue({signature}) }); vi.spyOn(pera, "verifyArc60Signature").mockResolvedValue(true); await expect( pera.signArc60Data(makePayload(), {scope: ScopeType.AUTH, encoding: "base64"}, true) ).resolves.toMatchObject({signature}); }); it("throws SIGN_DATA_VERIFICATION_FAILED when the ARC-60 signature does not check out", async () => { saveWalletDetailsToStorage([account.addr.toString()], "pera-wallet-extension"); const pera = new PeraWalletConnect(); vi.spyOn(pera as any, "getTransport").mockReturnValue({ signArc60Data: vi.fn().mockResolvedValue({signature: new Uint8Array([9])}) }); vi.spyOn(pera, "verifyArc60Signature").mockResolvedValue(false); await expect( pera.signArc60Data(makePayload(), {scope: ScopeType.AUTH, encoding: "base64"}, true) ).rejects.toMatchObject({data: {type: "SIGN_DATA_VERIFICATION_FAILED"}}); }); it("passes requestId and hdPath through when present, and omits them otherwise", async () => { saveWalletDetailsToStorage([account.addr.toString()], "pera-wallet"); const pera = new PeraWalletConnect(); vi.spyOn(pera as any, "getTransport").mockReturnValue({ signArc60Data: vi.fn().mockResolvedValue({signature: new Uint8Array([1])}) }); const withoutOptional = await pera.signArc60Data(makePayload(), { scope: ScopeType.AUTH, encoding: "base64" }); expect(withoutOptional).not.toHaveProperty("requestId"); expect(withoutOptional).not.toHaveProperty("hdPath"); const withOptional = await pera.signArc60Data( {...makePayload(), requestId: "req-1", hdPath: "m/44'/283'/0'/0/0"}, {scope: ScopeType.AUTH, encoding: "base64"} ); expect(withOptional.requestId).toBe("req-1"); expect(withOptional.hdPath).toBe("m/44'/283'/0'/0/0"); }); describe("with a scheme other than ed25519", () => { function makeFalconPayload() { return { ...makePayload(), signer: new Uint8Array(1793).fill(5), scheme: "falcon-1024-det" }; } it("resolves with the key and scheme it asked with", async () => { saveWalletDetailsToStorage([account.addr.toString()], "pera-wallet"); const pera = new PeraWalletConnect(); const signature = new Uint8Array(1230).fill(7); vi.spyOn(pera as any, "getTransport").mockReturnValue({ signArc60Data: vi.fn().mockResolvedValue({signature}) }); const payload = makeFalconPayload(); await expect( pera.signArc60Data(payload, {scope: ScopeType.AUTH, encoding: "base64"}) ).resolves.toEqual({...payload, signature}); }); // Verifying Falcon needs a Falcon implementation, which connect doesn't bundle. it("refuses verifySignature with SIGN_DATA_VERIFICATION_UNSUPPORTED before asking the wallet", async () => { saveWalletDetailsToStorage([account.addr.toString()], "pera-wallet"); const pera = new PeraWalletConnect(); const signArc60Data = vi.fn(); vi.spyOn(pera as any, "getTransport").mockReturnValue({signArc60Data}); await expect( pera.signArc60Data( makeFalconPayload(), {scope: ScopeType.AUTH, encoding: "base64"}, true ) ).rejects.toMatchObject({data: {type: "SIGN_DATA_VERIFICATION_UNSUPPORTED"}}); expect(signArc60Data).not.toHaveBeenCalled(); }); }); // Verification must decode `data` the same way the wire encoder does, using // real crypto rather than a mocked verifier — mocking it is what let the // encoding mismatch ship in the 1.6.0 betas. describe.each(["base64", "hex", "utf8"])( "verifies a genuine signature when encoding is %s", (encoding) => { it("accepts the wallet's signature over the decoded bytes", async () => { saveWalletDetailsToStorage([account.addr.toString()], "pera-wallet"); const authenticatorData = new Uint8Array(37).fill(3); const rawData = Buffer.from(`{"domain":"${window.location.origin}"}`, "utf8"); const payload = { data: rawData.toString(encoding as BufferEncoding), signer: algosdk.decodeAddress(account.addr.toString()).publicKey, domain: window.location.origin, authenticatorData }; // Sign exactly what ARC-60 says the wallet signs. const digest = async (bytes: Uint8Array) => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); const toBeSigned = new Uint8Array([ ...(await digest(new Uint8Array(rawData))), ...(await digest(authenticatorData)) ]); const signature = sign_detached(toBeSigned, account.sk); const pera = new PeraWalletConnect(); vi.spyOn(pera as any, "getTransport").mockReturnValue({ signArc60Data: vi.fn().mockResolvedValue({signature}) }); await expect( pera.signArc60Data(payload, {scope: ScopeType.AUTH, encoding}, true) ).resolves.toMatchObject({signature}); }); } ); });