Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions apps/extension/src/background/onboarding/onboarding-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import {
ARGENT_ACCOUNT_CLASS_HASH,
deploymentClasses,
XVERSE_ARGENT_CLASS_HASH,
} from "@bramble/wallet-model/account-families";
import type { AccountContractType } from "@bramble/wallet-model/accounts";
import {
Expand Down Expand Up @@ -117,20 +118,25 @@ const discoveryFingerprint = (
// Argent walks two ladders (issue 505): Ready derives its keys on an
// ETH-seeded tree ("argentx-bip44"), so every Argent release is scanned
// there, and the release this wallet creates is scanned once more on the
// wallet's own ladder for accounts this wallet made. Braavos keys live on the
// plain BIP-44 address axis, which the vault derivation maps to by family.
// wallet's own ladder for accounts this wallet made. Xverse derives on that
// same own ladder at its own Argent build (issue 734), so that class is scanned
// there and never on Ready's. Braavos keys live on the plain BIP-44 address
// axis, which the vault derivation maps to by family.
const recoveryScanShapes = (): readonly {
readonly accountContractType: AccountContractType;
readonly classHash?: string;
readonly derivation?: RecoveryDerivationKind;
}[] => [
{ accountContractType: "openzeppelin" },
{ accountContractType: "argent", classHash: ARGENT_ACCOUNT_CLASS_HASH },
...deploymentClasses("argent").map((entry) => ({
accountContractType: "argent" as const,
classHash: entry.classHash,
derivation: "argentx-bip44" as const,
})),
{ accountContractType: "argent", classHash: XVERSE_ARGENT_CLASS_HASH },
...deploymentClasses("argent")
.filter((entry) => !sameFelt(entry.classHash, XVERSE_ARGENT_CLASS_HASH))
.map((entry) => ({
accountContractType: "argent" as const,
classHash: entry.classHash,
derivation: "argentx-bip44" as const,
})),
...deploymentClasses("braavos").map((entry) => ({
accountContractType: "braavos" as const,
classHash: entry.classHash,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ import { sameFelt } from "../networks/networks";
export const ARGENT_ACCOUNT_CLASS_HASH =
"0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f";

/**
* The Argent 0.4 build Xverse deploys its Starknet accounts at since
* 2025-06-30 (xverse-core `argentXContractClassHashV0_4_0`). It is declared on
* Mainnet with Argent's ABI and the 0.4.0 constructor `(owner: Signer,
* guardian: Option<Signer>)`, but is not in Argent's own deployment list. Xverse
* derives keys on this wallet's own ladder (`m/44'/9004'/{i}'/0/0`) with salt =
* public key and no guardian, so its accounts are scanned there, never on
* Ready's ladder (issue 734).
*/
export const XVERSE_ARGENT_CLASS_HASH =
"0x0663fc01a0dbe1bacc4cd2a4c856eb9784b255a20988aa33d4d52b6fc20bd024";

/**
* Wallet families whose accounts this wallet can derive addresses for. OpenZeppelin
* is absent on purpose: its class hash is resolved from Krusty per network at
Expand All @@ -49,6 +61,8 @@ export type AccountFamily = "argent" | "braavos";
* What a class hash can be used for.
*
* `deployment` fixes an address, so it is the set to derive candidates from.
* It means "an account may have been deployed with this class", not "this
* wallet deploys it": deploying is gated separately in `transactions/deployment.ts`.
* `implementation` is what an upgraded account runs, so it is the set a
* deployed account may legitimately be found running. A class can be both.
*/
Expand Down Expand Up @@ -217,6 +231,14 @@ export const ACCOUNT_CLASSES: readonly AccountClassEntry[] = [
label: "argent 0.4.0",
roles: ["deployment", "implementation"],
},
{
authorizationShape: "argent-signer-array",
classHash: XVERSE_ARGENT_CLASS_HASH,
constructorShape: "signer-owner-guardian-option",
family: "argent",
label: "argent 0.4.0 (Xverse)",
roles: ["deployment", "implementation"],
},
];

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Xverse account address derivation (issue 734).
*
* The expected address is computed exactly the way xverse-core does it
* (`createAX040W0GContractDescription` + `getExpectedAccountContractAddress`,
* secretkeylabs/xverse-core `starknet/utils.ts`): the Argent build Xverse pins,
* `owner = Signer::Starknet(pubkey)`, `guardian = None`, salt = pubkey,
* deployer 0. Building it independently with starknet.js's Cairo encoders
* catches a wrong class or a wrong calldata layout, either of which yields a
* well-formed address that belongs to nobody.
*/
import { describe, expect, test } from "bun:test";
import { accountAddressCandidates } from "@bramble/starknet/account-contracts";
import { XVERSE_ARGENT_CLASS_HASH } from "@bramble/wallet-model/account-families";
import { CairoCustomEnum, CairoOption, CairoOptionVariant, CallData, ec, hash } from "starknet";

const sameFelt = (a: string, b: string): boolean =>
a.toLowerCase().replace(/^0x0*/, "") === b.toLowerCase().replace(/^0x0*/, "");

const xverseAddress = (publicKey: string): string =>
hash.calculateContractAddressFromHash(
publicKey,
"0x663fc01a0dbe1bacc4cd2a4c856eb9784b255a20988aa33d4d52b6fc20bd024",
CallData.compile({
// starknet.js numbers a variant by its key's position; Xverse lists
// Starknet first, as Argent's `Signer` enum does (variant 0). The other
// variants it lists after it are all unset, so they change nothing.
owner: new CairoCustomEnum({ Starknet: { signer: publicKey } }),
guardian: new CairoOption(CairoOptionVariant.None),
}),
0,
);

describe("Xverse account candidates", () => {
test("pins the class Xverse deploys at", () => {
expect(
sameFelt(
XVERSE_ARGENT_CLASS_HASH,
"0x663fc01a0dbe1bacc4cd2a4c856eb9784b255a20988aa33d4d52b6fc20bd024",
),
).toBe(true);
});

for (const privateKey of ["0x1", "0x2f5c1a7e", "0x6b86b273ff34fce19d6b804eff5a3f574"]) {
test(`reproduces Xverse's own address derivation for key ${privateKey}`, () => {
const publicKey = ec.starkCurve.getStarkKey(privateKey);
const hit = accountAddressCandidates(publicKey, "argent").find((candidate) =>
sameFelt(candidate.entry.classHash, XVERSE_ARGENT_CLASS_HASH),
);
expect(hit).toBeDefined();
expect(sameFelt(hit?.address ?? "0x0", xverseAddress(publicKey))).toBe(true);
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
BRAAVOS_BASE_CLASS_HASH,
BRAAVOS_DEPLOYMENT_CLASS_HASH,
deploymentClasses,
XVERSE_ARGENT_CLASS_HASH,
} from "@bramble/wallet-model/account-families";
import { ARGENT_ACCOUNT_CLASS_HASH, type DerivedAccountRef } from "@bramble/wallet-model/accounts";
import { RECOVERY_SCAN_LIMITS } from "@bramble/wallet-model/recovery";
Expand Down Expand Up @@ -380,6 +381,35 @@ describe("recovery scan Ready gate", () => {
);
});

test("says Bramble cannot deploy a funded Xverse account, and never offers an unused one", async () => {
// Issue 734: Xverse deploys on the first send, so a funded counterfactual is
// the common case. Deploy accepts only the pinned release, so the row must
// not promise a deployment; an unused Xverse address belongs to nobody.
const xverse = (address: string, accountIndex: number): DerivedAccountRef => ({
...brambleAccount,
accountContractType: "argent",
address,
classHash: XVERSE_ARGENT_CLASS_HASH,
deploymentSalt: "0x2",
derivation: { accountIndex, addressIndex: 0, kind: "starknet-bip44" },
id: `SN_MAIN:${address}`,
});
const app = mount([
{ account: xverse("0x7e51", 1), deployed: false, funded: true },
{ account: xverse("0x7e52", 2), deployed: false, funded: false },
]);
await reachPicker(app);

const rows = rowsIn(app)("argent");
const funded = rows.find((row) => row.textContent?.includes("Account 1"));
const unused = rows.find((row) => row.textContent?.includes("Account 2"));
expect(funded?.querySelector("strong")?.textContent).toBe("Account 1 · Argent 0.4.0 (Xverse)");
expect(funded?.textContent).toContain("Bramble cannot deploy this Argent release");
expect(funded?.textContent).not.toContain("ready to deploy");
expect(unused?.textContent).toContain("No on-chain activity found");
expect(unused?.querySelector<HTMLInputElement>("input.select-check")?.disabled).toBe(true);
});

test("stops auto-selecting once a restore would reject the selection", async () => {
const accounts = Array.from({ length: RECOVERY_SCAN_LIMITS.maxSelections + 1 }, (_, index) => {
const address = `0x${(index + 1).toString(16)}`;
Expand Down
136 changes: 136 additions & 0 deletions tests/background/onboarding/recovery-discovery-xverse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/** Xverse accounts are found on the wallet's own ladder at Xverse's Argent build (issue 734). */
import { describe, expect, test } from "bun:test";
import { createPreferencesStore } from "@bramble/settings/preferences";
import { createMemoryStore } from "@bramble/store/kv";
import type { AccountDerivationInput } from "@bramble/vault/accounts";
import { XVERSE_ARGENT_CLASS_HASH } from "@bramble/wallet-model/account-families";
import type { DerivedAccountRef } from "@bramble/wallet-model/accounts";
import { canonicalFelt, sameFelt } from "@bramble/wallet-model/networks";
import { handleOnboardingOps } from "../../../apps/extension/src/background/onboarding/onboarding-ops";
import { createRecoveryDiscoveryCache } from "../../../apps/extension/src/background/onboarding/recovery-cache";
import { defaultRuntimeRpc } from "../../../apps/extension/src/background/runtime/runtime-rpc";
import { createActiveStateCoordinator } from "../../../apps/extension/src/background/runtime/runtime-state";
import { createUnlockSession } from "../../../apps/extension/src/background/runtime/session";
import { walletStub } from "../../support/key-boundary-collaborators";

// One fake Xverse address per index, so a test can say which indices are
// deployed or funded on the fake chain. Every other preview is distinct.
const xverseAddress = (accountIndex: number): string => `0x7e5${accountIndex.toString(16)}`;

const isXverseOwnLadder = (input: AccountDerivationInput): boolean =>
input.accountContractType === "argent" &&
input.derivation === undefined &&
sameFelt(input.classHash ?? "0x0", XVERSE_ARGENT_CLASS_HASH);

const accountFor = (input: AccountDerivationInput, seen: number): DerivedAccountRef => {
const accountIndex = input.accountIndex ?? 0;
const address = isXverseOwnLadder(input)
? xverseAddress(accountIndex)
: `0x${(seen + 1).toString(16)}`;
const account = {
address,
classHash: canonicalFelt(input.classHash ?? "0x1") ?? "",
derivation: { accountIndex, addressIndex: 0, kind: "starknet-bip44" },
id: `SN_MAIN:${address}`,
networkId: "SN_MAIN",
publicKeyX: `0x${(seen + 100).toString(16)}`,
publicKeyY: `0x${(seen + 200).toString(16)}`,
} as const;
return input.accountContractType === "openzeppelin"
? { ...account, accountContractType: "openzeppelin" }
: {
...account,
accountContractType: input.accountContractType as "argent" | "braavos",
deploymentSalt: account.publicKeyX,
};
};

type Chain = { readonly deployed?: readonly string[]; readonly funded?: readonly string[] };
type Candidate = { readonly account: DerivedAccountRef; deployed: boolean; funded: boolean };

const discover = async (chain: Chain, manualIndex?: number) => {
const previews: AccountDerivationInput[] = [];
const has = (list: readonly string[] | undefined, address: string) =>
(list ?? []).some((entry) => sameFelt(entry, address));
const deps = {
activeStateCoordinator: createActiveStateCoordinator(),
preferencesStore: createPreferencesStore({ store: createMemoryStore() }),
recoveryDiscovery: createRecoveryDiscoveryCache(),
runtimeRpc: {
...defaultRuntimeRpc,
async getArgentSigningRestriction() {
return null;
},
async getDeploymentStatus({ accountAddress }: { readonly accountAddress: string }) {
return has(chain.deployed, accountAddress);
},
async getTokenBalances({
accountAddress,
tokens,
}: Parameters<typeof defaultRuntimeRpc.getTokenBalances>[0]) {
const funded = has(chain.funded, accountAddress);
return tokens.map((token) => {
const balance = funded && token.symbol === "STRK" ? "5" : "0";
return { ...token, balance, formatted: balance };
});
},
},
session: createUnlockSession(),
wallet: {
...walletStub,
async previewAccount(input: AccountDerivationInput) {
previews.push(input);
return accountFor(input, previews.length);
},
},
};
const response = (await handleOnboardingOps(deps, "wallet.discoverRecoveryAccounts", {
...(manualIndex === undefined ? {} : { manualIndex }),
mnemonic: "habit hope tip crystal because grunt nation idea electric witness alert like",
networkId: "SN_MAIN",
type: "wallet.discoverRecoveryAccounts",
})) as { readonly candidates?: readonly Candidate[] };
return { candidates: response.candidates ?? [], previews };
};

const xverseCandidate = (candidates: readonly Candidate[], accountIndex: number) =>
candidates.find((candidate) => sameFelt(candidate.account.address, xverseAddress(accountIndex)));

describe("Xverse recovery discovery", () => {
test("scans Xverse's Argent build once per index on the own ladder, never on Ready's", async () => {
const { previews } = await discover({});
const indices = [...new Set(previews.map((input) => input.accountIndex ?? 0))];
expect(indices.length).toBeGreaterThan(1);
for (const accountIndex of indices) {
const xverse = previews.filter(
(input) =>
(input.accountIndex ?? 0) === accountIndex &&
input.accountContractType === "argent" &&
sameFelt(input.classHash ?? "0x0", XVERSE_ARGENT_CLASS_HASH),
);
// Xverse derives on m/44'/9004'/{i}'/0/0, never on Ready's ETH-seeded tree.
expect(xverse.filter((input) => input.derivation === undefined)).toHaveLength(1);
expect(xverse.filter((input) => input.derivation === "argentx-bip44")).toHaveLength(0);
expect(xverse).toHaveLength(1);
}
});

test("lists a deployed Xverse account as a restorable candidate", async () => {
const { candidates } = await discover({ deployed: [xverseAddress(3)] }, 3);
const hit = xverseCandidate(candidates, 3);
expect(hit).toMatchObject({ deployed: true });
expect(hit?.account.accountContractType).toBe("argent");
expect(sameFelt(hit?.account.classHash ?? "0x0", XVERSE_ARGENT_CLASS_HASH)).toBe(true);
expect(hit?.account.derivation.kind).toBe("starknet-bip44");
});

test("lists a funded, undeployed Xverse account and drops an unfunded one", async () => {
// Xverse deploys lazily on the first send, so funded-but-undeployed is the
// common real case. It is found (the picker then says Bramble cannot deploy
// this release); an unused one belongs to nobody and is never offered.
const { candidates } = await discover({ funded: [xverseAddress(1)] });
expect(xverseCandidate(candidates, 1)).toMatchObject({ deployed: false, funded: true });
expect(xverseCandidate(candidates, 0)).toBeUndefined();
expect(xverseCandidate(candidates, 2)).toBeUndefined();
});
});
6 changes: 4 additions & 2 deletions tests/background/onboarding/recovery-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,10 @@ describe("recovery discovery", () => {

const vendor = argentShapes.filter((shape) => shape === "argentx-bip44");
const own = argentShapes.filter((shape) => shape === undefined);
expect(vendor.length).toBe(deploymentClasses("argent").length);
expect(own.length).toBe(1);
// Every Argent release on Ready's ladder except Xverse's build, which is
// scanned on the wallet's own ladder beside the pinned release (issue 734).
expect(vendor.length).toBe(deploymentClasses("argent").length - 1);
expect(own.length).toBe(2);
// Braavos never names a scheme; the vault maps its family to the vendor axis.
expect(braavosShapes.every((shape) => shape === undefined)).toBe(true);
expect(braavosShapes.length).toBe(deploymentClasses("braavos").length);
Expand Down
Loading