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
11 changes: 11 additions & 0 deletions src/__tests__/tokenFlowCertificates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,17 @@ describe("draftCertificateToBadge", () => {
color: "text-blue-500 dark:text-blue-400",
});
});

it("maps a vote-delegation DRep id", () => {
const badge = draftCertificateToBadge({
id: "c-1",
kind: "VoteDelegation",
dRepId: "Always Abstain",
origin: "user",
});
expect(badge.label).toBe("Vote Delegation");
expect(badge.detail).toBe("Always Abstain");
});
});

describe("meshVoteToBadge", () => {
Expand Down
22 changes: 22 additions & 0 deletions src/__tests__/txBuilderStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,28 @@ describe("tx-builder store creating stake actions and votes", () => {
expect(state.touched[outputId]).toBe(true);
});

it("register and a DRep id stay separate certificates", () => {
useTxBuilderStore.getState().addStakeAction({ type: "register" });
useTxBuilderStore.getState().addStakeAction({
type: "voteDelegation",
dRepId: "Always Abstain",
});
expect(
useTxBuilderStore.getState().draft.certificates.map((c) => ({
kind: c.kind,
dRepId: c.dRepId,
pairId: c.pairId,
})),
).toEqual([
{ kind: "RegisterStake", dRepId: undefined, pairId: undefined },
{
kind: "VoteDelegation",
dRepId: "Always Abstain",
pairId: undefined,
},
]);
});

it("removeCertificate drops a user-added pair atomically", () => {
useTxBuilderStore.getState().addStakeAction({
type: "registerAndDelegate",
Expand Down
32 changes: 30 additions & 2 deletions src/__tests__/txDraftFromTxJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,13 @@ describe("isDraftCompatible", () => {
},
],
[
"unsupported certificate types (VoteDelegation)",
"unsupported certificate types (StakeAndVoteDelegation)",
{
certificates: [
stakeCert({
type: "VoteDelegation",
type: "StakeAndVoteDelegation",
stakeKeyAddress: STAKE_ADDRESS,
poolKeyHash: "aa".repeat(28),
drep: { dRepId: "drep1abc" },
}),
],
Expand Down Expand Up @@ -481,6 +482,33 @@ describe("staking certificate transactions", () => {
expect(isDraftCompatible(deregister).compatible).toBe(true);
});

test("accepts VoteDelegation including Always Abstain", () => {
const drep = certBody([
stakeCert({
type: "VoteDelegation",
stakeKeyAddress: STAKE_ADDRESS,
drep: { dRepId: "drep1abc" },
}),
]);
expect(isDraftCompatible(drep).compatible).toBe(true);

const abstain = certBody([
stakeCert({
type: "VoteDelegation",
stakeKeyAddress: STAKE_ADDRESS,
drep: { alwaysAbstain: null },
}),
]);
expect(isDraftCompatible(abstain).compatible).toBe(true);

const { draft } = txJsonToDraft(abstain, { walletAddress: WALLET_ADDRESS });
expect(draft.certificates[0]).toMatchObject({
kind: "VoteDelegation",
dRepId: "Always Abstain",
originalStakeAddress: STAKE_ADDRESS,
});
});

test("cert-only body does not trip the no-outputs gate", () => {
const body = certBody([delegateCert()], { outputs: [] });
expect(isDraftCompatible(body).compatible).toBe(true);
Expand Down
30 changes: 30 additions & 0 deletions src/__tests__/txDraftMutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,36 @@ describe("tx-draft stake action mutations", () => {
expect(delegate!.pairId).toBe(register!.pairId);
});

test("register adds a single unpaired RegisterStake with no pool", () => {
const { draft, certificateIds } = addStakeAction(createDraft("d1"), {
type: "register",
});
expect(certificateIds).toHaveLength(1);
expect(draft.certificates).toEqual([
{
id: certificateIds[0],
kind: "RegisterStake",
origin: "user",
},
]);
});

test("voteDelegation stores the DRep id on its own certificate", () => {
const { draft, certificateIds } = addStakeAction(createDraft("d1"), {
type: "voteDelegation",
dRepId: "Always Abstain",
});
expect(certificateIds).toHaveLength(1);
expect(draft.certificates).toEqual([
{
id: certificateIds[0],
kind: "VoteDelegation",
dRepId: "Always Abstain",
origin: "user",
},
]);
});

test("delegate adds a single unpaired user cert with the pool", () => {
const { draft, certificateIds } = addStakeAction(createDraft("d1"), {
type: "delegate",
Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/txDraftToTxBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,26 @@ describe("applyDraftToTxBuilder certificates", () => {
"DeregisterStake",
);
});

test("VoteDelegation Always Abstain is a separate cert from RegisterStake", () => {
const { draft } = addStakeAction(
addStakeAction(createDraft("d1"), { type: "register" }).draft,
{ type: "voteDelegation", dRepId: "Always Abstain" },
);
const built = body(applyDraftToTxBuilder(bareTxBuilder(), draft, certCtx));
expect(built.certificates.map((c: any) => c.certType.type)).toEqual([
"RegisterStake",
"VoteDelegation",
]);
expect((built.certificates[1] as any).certType.drep).toEqual({
alwaysAbstain: null,
});
expect((built.certificates[1] as any).certType.poolId).toBeUndefined();
for (const cert of built.certificates) {
expect(cert.type).toBe("SimpleScriptCertificate");
expect((cert as any).certType.stakeKeyAddress).toBe(REWARD_ADDRESS);
}
});
});

describe("applyDraftToTxBuilder rationale edits", () => {
Expand Down
51 changes: 51 additions & 0 deletions src/__tests__/txDraftValidate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,57 @@ describe("validateDraft certificates", () => {
expect(validateDraft(deregisterOnly, active)).toEqual([]);
});

test("vote-delegation-only on an unregistered credential is an error", () => {
const voteOnly = addCertificate(createDraft("d1"), {
kind: "VoteDelegation",
dRepId: "Always Abstain",
}).draft;
expect(codes(validateDraft(voteOnly, inactive))).toEqual([
"cert-vote-unregistered",
]);
});

test("register + Always Abstain on an unregistered credential passes", () => {
let { draft } = addCertificate(createDraft("d1"), {
kind: "RegisterStake",
});
({ draft } = addCertificate(draft, {
kind: "VoteDelegation",
dRepId: "Always Abstain",
}));
expect(validateDraft(draft, inactive)).toEqual([]);
});

test("Always Abstain on a registered credential passes", () => {
const voteOnly = addCertificate(createDraft("d1"), {
kind: "VoteDelegation",
dRepId: "Always Abstain",
}).draft;
expect(validateDraft(voteOnly, active)).toEqual([]);
});

test("vote delegation without a target is an error", () => {
const missing = addCertificate(createDraft("d1"), {
kind: "VoteDelegation",
}).draft;
expect(codes(validateDraft(missing, { network: 0 }))).toEqual([
"cert-drep-missing",
]);
});

test("vote delegation accepts a real drep id and rejects junk", () => {
const drep = (dRepId: string) =>
addCertificate(createDraft("d1"), { kind: "VoteDelegation", dRepId })
.draft;
const valid = "drep1yw42424242424242424242424242424242424242424242sd640tm";
expect(validateDraft(drep(valid), active)).toEqual([]);
for (const bad of ["drep1abc", "aa".repeat(28), "Auto Abstain"]) {
expect(codes(validateDraft(drep(bad), active))).toEqual([
"cert-drep-missing",
]);
}
});

test("unknown state skips every registration check", () => {
const unknown = { network: 0, hasStakeContext: true };
expect(validateDraft(delegationDraft(POOL_ID), unknown)).toEqual([]);
Expand Down
100 changes: 90 additions & 10 deletions src/components/pages/wallet/build/add-stake-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GLASS_DIALOG_CLASS } from "@/components/common/token-flow/flow-canvas";
import { AUTO_ABSTAIN_DREP, isValidDrepId } from "@/lib/tx-draft/drep";
import type { StakeActionInput } from "@/lib/tx-draft/mutations";
import { useSiteStore } from "@/lib/zustand/site";
import { cn } from "@/lib/utils";
Expand All @@ -20,9 +22,10 @@ import { getProvider } from "@/utils/get-provider";
import { normalizePoolIdForDelegation } from "@/utils/normalizePoolId";
import { getFirstAndLast } from "@/utils/strings";

type StakeActionType = StakeActionInput["type"];
type StakeActionType = Exclude<StakeActionInput["type"], "voteDelegation">;

const ACTION_LABELS: Record<StakeActionType, string> = {
register: "Register stake credential",
registerAndDelegate: "Register & delegate",
delegate: "Change delegation",
deregister: "Deregister",
Expand Down Expand Up @@ -61,26 +64,33 @@ export default function AddStakeDialog({
}) {
const network = useSiteStore((state) => state.network);
const addStakeAction = useTxBuilderStore((state) => state.addStakeAction);
const presentKinds = useTxBuilderStore((state) =>
state.draft.certificates.map((certificate) => certificate.kind),
);

const [accountState, setAccountState] = useState<AccountState>(null);
const [actionType, setActionType] = useState<StakeActionType | null>(null);
const [manualPoolId, setManualPoolId] = useState("");
const [manualError, setManualError] = useState<string | null>(null);
const [dRepId, setDRepId] = useState(AUTO_ABSTAIN_DREP);
const [drepError, setDrepError] = useState<string | null>(null);

useEffect(() => {
if (!open) return;
setAccountState(null);
setActionType(null);
setManualPoolId("");
setManualError(null);
setDRepId(AUTO_ABSTAIN_DREP);
setDrepError(null);
let cancelled = false;
getProvider(network)
.get(`/accounts/${stakeAddress}`)
.then((data: { active?: boolean; pool_id?: string | null }) => {
if (cancelled) return;
const active = data.active === true;
setAccountState({ active, poolId: data.pool_id ?? null });
setActionType(active ? "delegate" : "registerAndDelegate");
setActionType(active ? "delegate" : "register");
})
.catch(() => {
if (!cancelled) setAccountState("error");
Expand All @@ -90,15 +100,40 @@ export default function AddStakeDialog({
};
}, [open, network, stakeAddress]);

const availableActions: StakeActionType[] =
const registrationActions: StakeActionType[] =
accountState === "error"
? ["registerAndDelegate", "delegate", "deregister"]
? ["register", "registerAndDelegate", "delegate", "deregister"]
: accountState?.active
? ["delegate", "deregister"]
: ["registerAndDelegate"];
: ["register", "registerAndDelegate"];

// The draft holds at most one certificate of each kind, and a draft that
// deregisters can't also register or delegate. Offer only what still fits.
const inDraft = (kind: (typeof presentKinds)[number]) =>
presentKinds.includes(kind);
const availableActions = registrationActions.filter((action) => {
switch (action) {
case "register":
return !inDraft("RegisterStake") && !inDraft("DeregisterStake");
case "registerAndDelegate":
return (
!inDraft("RegisterStake") &&
!inDraft("DelegateStake") &&
!inDraft("DeregisterStake")
);
case "delegate":
return !inDraft("DelegateStake") && !inDraft("DeregisterStake");
case "deregister":
return presentKinds.length === 0;
}
});
const canAddDrep =
!inDraft("VoteDelegation") && !inDraft("DeregisterStake");

const needsPool =
actionType === "registerAndDelegate" || actionType === "delegate";
actionType !== null &&
availableActions.includes(actionType) &&
(actionType === "registerAndDelegate" || actionType === "delegate");

/** Picking a pool IS the confirmation for pool-requiring actions. */
function commitWithPool(raw: string) {
Expand All @@ -116,8 +151,19 @@ export default function AddStakeDialog({
}
}

function onConfirmDeregister() {
addStakeAction({ type: "deregister" });
function onConfirmNoPool() {
if (actionType !== "register" && actionType !== "deregister") return;
addStakeAction({ type: actionType });
onOpenChange(false);
}

function onAddDrep() {
const value = dRepId.trim();
if (!isValidDrepId(value)) {
setDrepError(`Enter "${AUTO_ABSTAIN_DREP}" or a drep1... id.`);
return;
}
addStakeAction({ type: "voteDelegation", dRepId: value });
onOpenChange(false);
}

Expand Down Expand Up @@ -221,16 +267,50 @@ export default function AddStakeDialog({
<PoolSelector onSelect={(poolHex) => commitWithPool(poolHex)} />
</>
)}

{accountState !== null && canAddDrep && (
<div className="flex flex-col gap-1.5 border-t border-border/50 pt-4">
<Label htmlFor="stake-drep-input" className="text-xs">
Governance delegation (DRep) added as a separate certificate
</Label>
<div className="flex items-center gap-2">
<Input
id="stake-drep-input"
data-testid="tx-builder-vote-drep-input"
value={dRepId}
placeholder={`${AUTO_ABSTAIN_DREP} or drep1...`}
onChange={(event) => {
setDRepId(event.target.value);
setDrepError(null);
}}
/>
<Button
size="sm"
disabled={!dRepId.trim()}
data-testid="tx-builder-vote-drep-apply"
onClick={onAddDrep}
>
Add
</Button>
</div>
{drepError && (
<p className="text-xs text-red-500 dark:text-red-400">
{drepError}
</p>
)}
</div>
)}
</div>

<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{actionType === "deregister" && (
{(actionType === "register" || actionType === "deregister") &&
availableActions.includes(actionType) && (
<Button
data-testid="tx-builder-stake-confirm"
onClick={onConfirmDeregister}
onClick={onConfirmNoPool}
>
Add to transaction
</Button>
Expand Down
Loading
Loading