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
90 changes: 90 additions & 0 deletions src/__tests__/summonWallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { buildWallet } from "../utils/common";
import { Wallet as DbWallet } from "@prisma/client";
import { RawImportBodies } from "../types/wallet";

describe("Summon Wallet Capabilities", () => {
const network = 0; // Testnet

const mockSummonWallet: DbWallet & { rawImportBodies: RawImportBodies } = {
id: "test-summon-uuid",
name: "Test Summon Wallet",
description: "A test summon wallet",
address: "addr_test1wpnlxv2xv988tvv9z06m6pax76r98slymr6uzy958tclv6sgp98k8",
type: "atLeast",
numRequiredSigners: 2,
signersAddresses: ["addr_test1vpu5vl76u73su6p0657cw6q0657cw6q0657cw6q0657cw6q0657cw"],
signersStakeKeys: [],
signersDRepKeys: [],
scriptCbor: "8201828200581caf000000000000000000000000000000000000000000000000000000008200581cb0000000000000000000000000000000000000000000000000000000",
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
profileImageIpfsUrl: null,
stakeCredentialHash: null,
dRepId: "",
rawImportBodies: {
multisig: {
address: "addr_test1wpnlxv2xv988tvv9z06m6pax76r98slymr6uzy958tclv6sgp98k8",
payment_script: "8200581c00000000000000000000000000000000000000000000000000000000",
stake_script: "8200581c11111111111111111111111111111111111111111111111111111111",
}
}
} as any;

it("should correctly populate capabilities for a Summon wallet with staking", () => {
const wallet = buildWallet(mockSummonWallet, network);

expect(wallet.capabilities).toBeDefined();
expect(wallet.capabilities!.canStake).toBe(true);
expect(wallet.capabilities!.canVote).toBe(false);
expect(wallet.capabilities!.address).toBe(mockSummonWallet.rawImportBodies.multisig!.address);
expect(wallet.capabilities!.stakeAddress).toBeDefined();
expect(wallet.capabilities!.stakeAddress).toMatch(/^stake_test/);
});

it("should correctly populate capabilities for a Summon wallet without staking", () => {
const mockNoStake = {
...mockSummonWallet,
rawImportBodies: {
multisig: {
...mockSummonWallet.rawImportBodies.multisig,
stake_script: undefined
}
}
};
const wallet = buildWallet(mockNoStake, network);

expect(wallet.capabilities!.canStake).toBe(false);
expect(wallet.capabilities!.stakeAddress).toBeUndefined();
});

it("should correctly handle Summon wallets with unordered CBOR lists", () => {
// Swap the two sigs in the CBOR string to make it "unordered"
// Original: 82 01 82 [sigA] [sigB]
// [sigA] = 8200581caf00000000000000000000000000000000000000000000000000000000 (32 bytes = 64 chars)
// [sigB] = 8200581cb00000000000000000000000000000000000000000000000000000000 (32 bytes = 64 chars)
const sigA = "8200581caf00000000000000000000000000000000000000000000000000000000";
const sigB = "8200581cb00000000000000000000000000000000000000000000000000000000";
const unorderedCbor = "820182" + sigB + sigA;

const mockUnordered = {
...mockSummonWallet,
rawImportBodies: {
multisig: {
...mockSummonWallet.rawImportBodies.multisig,
payment_script: unorderedCbor
}
}
};

const wallet = buildWallet(mockUnordered, network);

// The address should still be the one from metadata, even if we can't decode the "unordered" CBOR correctly
// This ensures compatibility with legacy scripts that might not follow modern canonical rules.
expect(wallet.capabilities!.address).toBe(mockSummonWallet.rawImportBodies.multisig!.address);
expect(wallet.scriptCbor).toBe(unorderedCbor);

// Even if decoding fails, we should still have a nativeScript object (fallback)
expect(wallet.nativeScript).toBeDefined();
});
});
36 changes: 11 additions & 25 deletions src/components/pages/homepage/wallets/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ export default function PageWallets() {
retry: (failureCount, error) => {
// Don't retry on authorization errors (403)
if (error && typeof error === "object") {
const err = error as {
code?: string;
message?: string;
const err = error as {
code?: string;
message?: string;
data?: { code?: string; httpStatus?: number };
shape?: { code?: string; message?: string };
};
Expand All @@ -83,9 +83,9 @@ export default function PageWallets() {
retry: (failureCount, error) => {
// Don't retry on authorization errors (403)
if (error && typeof error === "object") {
const err = error as {
code?: string;
message?: string;
const err = error as {
code?: string;
message?: string;
data?: { code?: string; httpStatus?: number };
shape?: { code?: string; message?: string };
};
Expand All @@ -108,8 +108,8 @@ export default function PageWallets() {
const walletsForBalance = useMemo(
() =>
wallets?.filter((wallet) => showArchived || !wallet.isArchived) as
| Wallet[]
| undefined,
| Wallet[]
| undefined,
[wallets, showArchived],
);

Expand Down Expand Up @@ -257,21 +257,7 @@ function CardWallet({

// Rebuild the multisig wallet to get the correct canonical address for display
// This ensures we show the correct address even if wallet.address was built incorrectly
const displayAddress = useMemo(() => {
try {
const walletNetwork = wallet.signersAddresses.length > 0
? addressToNetwork(wallet.signersAddresses[0]!)
: network;
const mWallet = buildMultisigWallet(wallet, walletNetwork);
if (mWallet) {
return mWallet.getScript().address;
}
} catch (error) {
console.error(`Error building wallet for display: ${wallet.id}`, error);
}
// Fallback to wallet.address if rebuild fails (legacy support)
return wallet.address;
}, [wallet, network]);
const displayAddress = wallet.capabilities?.address || wallet.address;

return (
<Link href={`/wallets/${wallet.id}`}>
Expand All @@ -293,8 +279,8 @@ function CardWallet({
}
headerDom={
isSummonWallet ? (
<Badge
variant="outline"
<Badge
variant="outline"
className="text-xs bg-orange-600/10 border-orange-600/30 text-orange-700 dark:text-orange-400"
>
<Archive className="h-3 w-3 mr-1" />
Expand Down
126 changes: 58 additions & 68 deletions src/components/pages/wallet/info/card-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { getWalletType } from "@/utils/common";

export default function CardInfo({ appWallet }: { appWallet: Wallet }) {
const [showEdit, setShowEdit] = useState(false);

// Check if this is a legacy wallet using the centralized detection
const walletType = getWalletType(appWallet);
const isLegacyWallet = walletType === 'legacy';
Expand All @@ -70,8 +70,8 @@ export default function CardInfo({ appWallet }: { appWallet: Wallet }) {
headerDom={
<div className="flex items-center gap-2">
{isLegacyWallet && (
<Badge
variant="outline"
<Badge
variant="outline"
className="text-xs bg-orange-400/10 border-orange-400/30 text-orange-600 dark:text-orange-300"
>
<Archive className="h-3 w-3 mr-1" />
Expand Down Expand Up @@ -184,7 +184,7 @@ function EditInfo({
initialUrl={profileImageIpfsUrl}
/>
<p className="text-xs text-muted-foreground">
<strong>Note:</strong> Images will be stored on public IPFS (InterPlanetary File System).
<strong>Note:</strong> Images will be stored on public IPFS (InterPlanetary File System).
Once uploaded, the image will be publicly accessible and cannot be removed from IPFS.
</p>
</div>
Expand Down Expand Up @@ -220,17 +220,17 @@ function EditInfo({
onClick={() => editWallet()}
disabled={
loading ||
(appWallet.name === name &&
appWallet.description === description &&
appWallet.isArchived === isArchived &&
appWallet.profileImageIpfsUrl === profileImageIpfsUrl)
(appWallet.name === name &&
appWallet.description === description &&
appWallet.isArchived === isArchived &&
appWallet.profileImageIpfsUrl === profileImageIpfsUrl)
}
className="flex-1 sm:flex-initial"
>
{loading ? "Updating Wallet..." : "Update"}
</Button>
<Button
onClick={() => setShowEdit(false)}
<Button
onClick={() => setShowEdit(false)}
variant="outline"
className="flex-1 sm:flex-initial"
>
Expand All @@ -246,7 +246,7 @@ function MultisigScriptSection({ mWallet }: { mWallet: MultisigWallet }) {
const { appWallet } = useAppWallet();
const walletsUtxos = useWalletsStore((state) => state.walletsUtxos);
const [balance, setBalance] = useState<number>(0);

useEffect(() => {
if (!appWallet) return;
const utxos = walletsUtxos[appWallet.id];
Expand All @@ -266,7 +266,7 @@ function MultisigScriptSection({ mWallet }: { mWallet: MultisigWallet }) {
<Code className="block text-xs sm:text-sm whitespace-pre">{JSON.stringify(mWallet?.getJsonMetadata(), null, 2)}</Code>
</div>
</div>

{/* Register Wallet Section */}
<div className="pt-3 border-t border-border/30">
<div className="mb-2">
Expand Down Expand Up @@ -377,7 +377,7 @@ function ShowInfo({ appWallet }: { appWallet: Wallet }) {
const { multisigWallet } = useMultisigWallet();
const walletsUtxos = useWalletsStore((state) => state.walletsUtxos);
const [balance, setBalance] = useState<number>(0);

useEffect(() => {
if (!appWallet) return;
const utxos = walletsUtxos[appWallet.id];
Expand All @@ -390,18 +390,12 @@ function ShowInfo({ appWallet }: { appWallet: Wallet }) {
// Check if this is a legacy wallet using the centralized detection
const walletType = getWalletType(appWallet);
const isLegacyWallet = walletType === 'legacy';

// For legacy wallets, multisigWallet will be undefined, so use appWallet.address
// For SDK wallets, prefer the address from multisigWallet if staking is enabled
const address = multisigWallet?.getKeysByRole(2) ? multisigWallet?.getScript().address : appWallet.address;

// Get DRep ID from multisig wallet if available (it handles no DRep keys by using payment script),
// otherwise fallback to appWallet (for legacy wallets without multisigWallet)
const dRepId = multisigWallet ? multisigWallet.getDRepId() : appWallet?.dRepId;

// For rawImportBodies wallets, dRepId may not be available
const showDRepId = dRepId && dRepId.length > 0;


// Use capabilities for address and DRep ID (Type 2 Summon, Type 1 SDK, Type 0 Legacy)
const address = appWallet.capabilities?.address || appWallet.address;
const dRepId = appWallet.capabilities?.dRepId || appWallet.dRepId;
const showDRepId = !!appWallet.capabilities?.canVote && !!dRepId;

// Calculate signers info
const signersCount = appWallet.signersAddresses.length;
const requiredSigners = appWallet.numRequiredSigners ?? signersCount;
Expand All @@ -414,7 +408,7 @@ function ShowInfo({ appWallet }: { appWallet: Wallet }) {
return `${requiredSigners} of ${signersCount} signers`;
}
};

// Get the number of required signers for visualization
const getRequiredCount = () => {
if (appWallet.type === 'all') {
Expand All @@ -425,40 +419,39 @@ function ShowInfo({ appWallet }: { appWallet: Wallet }) {
return requiredSigners;
}
};

const requiredCount = getRequiredCount();

return (
<div className="space-y-6">
{/* Top Section: Key Info */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 w-full">
{/* Signing Threshold */}
<div className="flex items-center gap-3 p-4 bg-muted/40 rounded-lg border border-border/40">
<div className="flex items-center gap-1.5 flex-shrink-0">
{Array.from({ length: signersCount }).map((_, index) => (
<User
key={index}
className={`h-4 w-4 sm:h-5 sm:w-5 ${
index < requiredCount
? "text-foreground opacity-100"
: "text-muted-foreground opacity-30"
{/* Signing Threshold */}
<div className="flex items-center gap-3 p-4 bg-muted/40 rounded-lg border border-border/40">
<div className="flex items-center gap-1.5 flex-shrink-0">
{Array.from({ length: signersCount }).map((_, index) => (
<User
key={index}
className={`h-4 w-4 sm:h-5 sm:w-5 ${index < requiredCount
? "text-foreground opacity-100"
: "text-muted-foreground opacity-30"
}`}
/>
))}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-0.5">Signing Threshold</div>
<div className="text-sm font-semibold">{getSignersText()}</div>
</div>
/>
))}
</div>

{/* Balance */}
<div className="flex flex-col justify-center p-4 bg-muted/40 rounded-lg border border-border/40">
<div className="text-xs font-medium text-muted-foreground mb-1">Balance</div>
<div className="text-2xl sm:text-3xl font-bold">{balance} ₳</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-0.5">Signing Threshold</div>
<div className="text-sm font-semibold">{getSignersText()}</div>
</div>
</div>

{/* Balance */}
<div className="flex flex-col justify-center p-4 bg-muted/40 rounded-lg border border-border/40">
<div className="text-xs font-medium text-muted-foreground mb-1">Balance</div>
<div className="text-2xl sm:text-3xl font-bold">{balance} ₳</div>
</div>
</div>

{/* Addresses Section */}
<div className="space-y-3 pt-2 border-t border-border/30">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">Wallet Details</div>
Expand All @@ -470,20 +463,17 @@ function ShowInfo({ appWallet }: { appWallet: Wallet }) {
copyString={address}
allowOverflow={false}
/>

{/* Stake Address - Show if staking is enabled */}
{multisigWallet && multisigWallet.stakingEnabled() && (() => {
const stakeAddress = multisigWallet.getStakeAddress();
return stakeAddress ? (
<RowLabelInfo
label="Stake Key"
value={getFirstAndLast(stakeAddress, 20, 15)}
copyString={stakeAddress}
allowOverflow={false}
/>
) : null;
})()}


{/* Stake Address - Show if staking is supported (Summon or SDK) */}
{appWallet.capabilities?.canStake && appWallet.capabilities?.stakeAddress && (
<RowLabelInfo
label="Stake Key"
value={getFirstAndLast(appWallet.capabilities.stakeAddress, 20, 15)}
copyString={appWallet.capabilities.stakeAddress}
allowOverflow={false}
/>
)}

{/* External Stake Key Hash - Always show if available */}
{appWallet?.stakeCredentialHash && (
<RowLabelInfo
Expand All @@ -493,7 +483,7 @@ function ShowInfo({ appWallet }: { appWallet: Wallet }) {
allowOverflow={false}
/>
)}

{/* DRep ID */}
{showDRepId && dRepId ? (
<RowLabelInfo
Expand All @@ -511,10 +501,10 @@ function ShowInfo({ appWallet }: { appWallet: Wallet }) {
) : null}
</div>
</div>

{/* Native Script - Collapsible Pro Feature */}
<div className="pt-2 border-t border-border/30">
{multisigWallet && multisigWallet.stakingEnabled() ? (
{multisigWallet && appWallet.capabilities?.canStake ? (
<MultisigScriptSection mWallet={multisigWallet} />
) : (
<NativeScriptSection appWallet={appWallet} />
Expand Down
Loading