Skip to content
Open
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
18 changes: 18 additions & 0 deletions .changeset/add-coldcard-address-verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"caravan-coordinator": minor
"@caravan/wallets": minor
---

Add address verification support for Coldcard hardware wallets

Implements ColdcardConfirmMultisigAddress interaction for manual address verification via address explorer. Users can now verify multisig addresses on Coldcard devices by:
1. Selecting Coldcard from the address verification dropdown (now enabled)
2. Receiving clear instructions for manual verification using popular block explorers
3. Manually confirming the address matches their device
4. Completing the verification workflow

This enables Coldcard to achieve feature parity with other supported hardware wallets (Trezor, Ledger, Jade, BitBox) for address verification. Direct on-device verification via message signing can be added in future when Coldcard implements that capability.

Includes comprehensive E2E test coverage for the new address verification flow.

Fixes #476
97 changes: 97 additions & 0 deletions apps/coordinator/e2e/tests/01-multisig_setup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,101 @@ test.describe("Caravan Wallet Creation", () => {
downloadDirFiles: { WalletFile: downloadedWalletFile },
});
});

test("should support Coldcard address verification in UI", async ({ page }) => {
// Setup wallet
await setupPrivateClient(page, {});
await expect(page.getByText("Connection Success!")).toBeVisible();

// Create a simple 2-of-2 multisig
await page
.locator("input[name='network'][value='regtest']")
.setChecked(true);

const { descriptors } = await extractMultiWalletDescriptors(
walletNames.slice(0, 2),
client,
"p2pkh",
);
const xpub1 = descriptors[0].xpub;
const xpub2 = descriptors[1].xpub;

// Fill first xpub
await page.click("div#public-key-1-importer-select[role='combobox']");
await page.click(
"li[role='option'][data-value='text']:has-text('Enter as text')",
);
await page.locator('textarea[name="publicKey"]').fill(xpub1);
await page.click("button[type=button]:has-text('Enter')");

// Fill second xpub
await page.click("div#public-key-2-importer-select[role='combobox']");
await page.click(
"li[role='option'][data-value='text']:has-text('Enter as text')",
);
await page.locator('textarea[name="publicKey"]').fill(xpub2);
await page.click("button[type=button]:has-text('Enter')");

// Change to regtest and generate address
await page.click("button:has-text('Generate Address')");
await expect(page.getByText(/bc1|2|3/)).toBeVisible();

// Navigate to address details/Confirm on Device tab
await page.click("button:has-text('Confirm on Device')");
await expect(
page.getByText("Verify Address with Quorum Participants"),
).toBeVisible();

// Select first key for verification
await page.click("div[id='public-key-selector-select'][role='combobox']");
await page.click("li[role='option']:first-child");

// Verify that Coldcard now appears in the dropdown (not disabled)
await page.click("div#confirm-importer-select[role='combobox']");

// Check that Coldcard option exists and is NOT disabled
const coldcardOption = page.locator(
"li[role='option'][data-value='coldcard']",
);
await expect(coldcardOption).toBeVisible();

// Verify the option is not disabled by checking it's clickable
const isDisabled = await coldcardOption.evaluate((el: any) =>
el.hasAttribute("aria-disabled"),
);
expect(isDisabled).toBe(false);

// Click Coldcard option
await coldcardOption.click();

// Verify manual verification instructions appear
await expect(
page.getByText(
/Coldcard address verification is performed manually via address explorer/,
),
).toBeVisible();

await expect(
page.getByText(
/Verify the address matches your Coldcard using an address explorer/,
),
).toBeVisible();

await expect(
page.getByText(
/Direct message signing on Coldcard for address verification is not yet supported/,
),
).toBeVisible();

// Verify registration helper button appears for Coldcard config install
await expect(
page.locator("button:has-text('Download Coldcard Config')"),
).toBeVisible();

// Verify Confirm button exists and is enabled
const confirmButton = page.locator(
"button:has-text('Confirm'):not([disabled])",
);
await expect(confirmButton).toBeVisible();
});
});
29 changes: 21 additions & 8 deletions apps/coordinator/src/components/Slices/ConfirmAddress.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,16 @@ import { slicePropTypes } from "../../proptypes";
import { getWalletConfig } from "../../selectors/wallet";
import { BCUR2Encoder } from "../BCUR2";
import { RegisterBCUR2Button } from "../RegisterWallet/RegisterBCUR2Button";
import DownloadColdardConfigButton from "../RegisterWallet/DownloadColdcardConfig";

const TEXT = "text";
const SUPPORTED_CONFIRMATION_METHODS = new Set([
JADE,
BITBOX,
TREZOR,
LEDGER,
COLDCARD,
]);

const initialInteractionState = {
keySelected: false,
Expand Down Expand Up @@ -143,12 +151,8 @@ const ConfirmAddress = ({ slice, network }) => {
value: "",
});
}
// FIXME - hardcoded to just show up for trezor
if (
extendedPublicKeyImporter.method === JADE ||
extendedPublicKeyImporter.method === BITBOX ||
extendedPublicKeyImporter.method === TREZOR ||
extendedPublicKeyImporter.method === LEDGER
SUPPORTED_CONFIRMATION_METHODS.has(extendedPublicKeyImporter.method)
) {
setInteraction(
ConfirmMultisigAddress({
Expand Down Expand Up @@ -253,9 +257,7 @@ const ConfirmAddress = ({ slice, network }) => {
<MenuItem value={TREZOR}>Trezor</MenuItem>
<MenuItem value={LEDGER}>Ledger</MenuItem>
<MenuItem value={BCUR2}>BCUR2</MenuItem>
<MenuItem value={COLDCARD} disabled>
Coldcard
</MenuItem>
<MenuItem value={COLDCARD}>Coldcard</MenuItem>
<MenuItem value={HERMIT} disabled>
Hermit
</MenuItem>
Expand Down Expand Up @@ -292,6 +294,17 @@ const ConfirmAddress = ({ slice, network }) => {
</TableBody>
</Table>
</Box>
{state.deviceType === COLDCARD && (
<Box my={2}>
<Typography variant="caption" component="p">
If not already done, install the multisig wallet config on your
Coldcard first.
</Typography>
<Box mt={1}>
<DownloadColdardConfigButton />
</Box>
</Box>
)}
{state.interactionMessage !== "" && (
<Box mt={2} align="center">
<Typography variant="h5" style={{ color: "green" }}>
Expand Down
42 changes: 36 additions & 6 deletions apps/coordinator/src/components/Wallet/AddressExpander.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,24 @@ import {
multisigAddressType,
Network,
} from "@caravan/bitcoin";
import { PENDING, ACTIVE, ConfirmMultisigAddress } from "@caravan/wallets";
import {
PENDING,
ACTIVE,
ConfirmMultisigAddress,
JADE,
BITBOX,
TREZOR,
LEDGER,
COLDCARD,
} from "@caravan/wallets";
import LaunchIcon from "@mui/icons-material/Launch";
import UTXOSet from "../ScriptExplorer/UTXOSet";
import MultisigDetails from "../MultisigDetails";
import ImportAddressesButton from "../ImportAddressesButton";
import Copyable from "../Copyable";
import InteractionMessages from "../InteractionMessages";
import ExtendedPublicKeySelector from "./ExtendedPublicKeySelector";
import DownloadColdardConfigButton from "../RegisterWallet/DownloadColdcardConfig";

import styles from "../ScriptExplorer/styles.module.scss";

Expand All @@ -45,6 +55,13 @@ const MODE_REDEEM = 1;
const MODE_CONFIRM = 2;
const MODE_WATCH = 3;
let anchor;
const CONFIRMABLE_KEYSTORE_METHODS = new Set([
JADE,
BITBOX,
TREZOR,
LEDGER,
COLDCARD,
]);

class AddressExpander extends React.Component {
interaction = null;
Expand All @@ -60,6 +77,7 @@ class AddressExpander extends React.Component {
interactionState: PENDING,
interactionError: "",
interactionMessage: "",
selectedMethod: "",
};
}

Expand Down Expand Up @@ -156,10 +174,8 @@ class AddressExpander extends React.Component {

canConfirm = () => {
const { extendedPublicKeyImporters } = this.props;
return (
Object.values(extendedPublicKeyImporters).filter(
(importer) => importer.method === "trezor",
).length > 0
return Object.values(extendedPublicKeyImporters).some((importer) =>
CONFIRMABLE_KEYSTORE_METHODS.has(importer.method),
);
};

Expand Down Expand Up @@ -300,6 +316,17 @@ class AddressExpander extends React.Component {
})}
/>
)}
{this.state.selectedMethod === COLDCARD && (
<Box my={2}>
<Typography variant="caption" component="p">
If not already done, install the multisig wallet config on
your Coldcard first.
</Typography>
<Box mt={1}>
<DownloadColdardConfigButton />
</Box>
</Box>
)}
<Button
variant="contained"
size="large"
Expand Down Expand Up @@ -368,7 +395,10 @@ class AddressExpander extends React.Component {
bip32Path: `${extendedPublicKeyImporter.bip32Path}${bip32Path.slice(1)}`,
multisig,
});
this.setState({ hasInteraction: true });
this.setState({
hasInteraction: true,
selectedMethod: extendedPublicKeyImporter.method,
});
this.resetInteractionState();
};

Expand Down
60 changes: 60 additions & 0 deletions packages/caravan-wallets/src/coldcard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ColdcardExportPublicKey,
ColdcardExportExtendedPublicKey,
ColdcardSignMultisigTransaction,
ColdcardConfirmMultisigAddress,
ColdcardMultisigWalletConfig,
} from "./coldcard";
import { coldcardFixtures } from "./fixtures/coldcard.fixtures";
Expand Down Expand Up @@ -645,6 +646,65 @@ describe("ColdcardSignMultisigTransaction", () => {
});
});

describe("ColdcardConfirmMultisigAddress", () => {
const ADDRESS = "3QJmV3qfvL9SuYo34YihAf3sRCW3qSinyC";
const BIP32_PATH = "m/45'/0/0";

function interactionBuilder({
address = ADDRESS,
bip32Path = BIP32_PATH,
} = {}) {
return new ColdcardConfirmMultisigAddress({
address,
bip32Path,
});
}

it("returns manual verification instructions", () => {
const interaction = interactionBuilder();

expect(
interaction.hasMessagesFor({
state: PENDING,
level: INFO,
code: "coldcard.confirm_address.install_multisig_config",
})
).toBe(true);

expect(
interaction.hasMessagesFor({
state: PENDING,
level: INFO,
code: "coldcard.confirm_address.manual_verification",
})
).toBe(true);

expect(
interaction.hasMessagesFor({
state: PENDING,
level: INFO,
code: "coldcard.confirm_address.check_xpub",
})
).toBe(true);

expect(
interaction.hasMessagesFor({
state: PENDING,
level: INFO,
code: "coldcard.confirm_address.no_direct_signing",
})
).toBe(true);
});

it("returns address and serialized path on run", async () => {
const interaction = interactionBuilder();
await expect(interaction.run()).resolves.toEqual({
address: ADDRESS,
serializedPath: BIP32_PATH,
});
});
});

describe("ColdcardMultisigWalletConfig", () => {
let jsonConfigCopy: any = {};

Expand Down
Loading