From 94ae35361f632992a9dafdabc58f4f3520aa978e Mon Sep 17 00:00:00 2001 From: "Emmanuel .A" Date: Wed, 7 Oct 2026 10:27:52 +0100 Subject: [PATCH] feat: multisig wallets register a stake credential without picking an SPO --- src/__tests__/tokenFlowCertificates.test.ts | 11 ++ src/__tests__/txBuilderStore.test.ts | 22 ++++ src/__tests__/txDraftFromTxJson.test.ts | 32 +++++- src/__tests__/txDraftMutations.test.ts | 30 ++++++ src/__tests__/txDraftToTxBuilder.test.ts | 20 ++++ src/__tests__/txDraftValidate.test.ts | 51 +++++++++ .../pages/wallet/build/add-stake-dialog.tsx | 100 ++++++++++++++++-- src/components/pages/wallet/build/index.tsx | 4 +- .../build/inspector/certificate-editor.tsx | 13 +++ src/lib/tx-draft/drep.ts | 15 +++ src/lib/tx-draft/from-tx-json.ts | 20 +++- src/lib/tx-draft/mutations.ts | 17 ++- src/lib/tx-draft/to-tx-builder.ts | 16 +++ src/lib/tx-draft/validate.ts | 48 ++++++--- src/lib/tx-review/spec.ts | 1 + src/types/tx-draft.ts | 14 ++- src/utils/token-flow/certificates.ts | 11 ++ 17 files changed, 386 insertions(+), 39 deletions(-) create mode 100644 src/lib/tx-draft/drep.ts diff --git a/src/__tests__/tokenFlowCertificates.test.ts b/src/__tests__/tokenFlowCertificates.test.ts index 5120643c..204f1833 100644 --- a/src/__tests__/tokenFlowCertificates.test.ts +++ b/src/__tests__/tokenFlowCertificates.test.ts @@ -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", () => { diff --git a/src/__tests__/txBuilderStore.test.ts b/src/__tests__/txBuilderStore.test.ts index 9ab658de..5e15ffaf 100644 --- a/src/__tests__/txBuilderStore.test.ts +++ b/src/__tests__/txBuilderStore.test.ts @@ -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", diff --git a/src/__tests__/txDraftFromTxJson.test.ts b/src/__tests__/txDraftFromTxJson.test.ts index 4dcfc485..ba3497aa 100644 --- a/src/__tests__/txDraftFromTxJson.test.ts +++ b/src/__tests__/txDraftFromTxJson.test.ts @@ -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" }, }), ], @@ -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); diff --git a/src/__tests__/txDraftMutations.test.ts b/src/__tests__/txDraftMutations.test.ts index eafe9b42..7ac7240f 100644 --- a/src/__tests__/txDraftMutations.test.ts +++ b/src/__tests__/txDraftMutations.test.ts @@ -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", diff --git a/src/__tests__/txDraftToTxBuilder.test.ts b/src/__tests__/txDraftToTxBuilder.test.ts index 2c25e654..42fa19e0 100644 --- a/src/__tests__/txDraftToTxBuilder.test.ts +++ b/src/__tests__/txDraftToTxBuilder.test.ts @@ -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", () => { diff --git a/src/__tests__/txDraftValidate.test.ts b/src/__tests__/txDraftValidate.test.ts index 2999528f..ad17cd45 100644 --- a/src/__tests__/txDraftValidate.test.ts +++ b/src/__tests__/txDraftValidate.test.ts @@ -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([]); diff --git a/src/components/pages/wallet/build/add-stake-dialog.tsx b/src/components/pages/wallet/build/add-stake-dialog.tsx index 99af8f8a..210dbf81 100644 --- a/src/components/pages/wallet/build/add-stake-dialog.tsx +++ b/src/components/pages/wallet/build/add-stake-dialog.tsx @@ -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"; @@ -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; const ACTION_LABELS: Record = { + register: "Register stake credential", registerAndDelegate: "Register & delegate", delegate: "Change delegation", deregister: "Deregister", @@ -61,11 +64,16 @@ 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(null); const [actionType, setActionType] = useState(null); const [manualPoolId, setManualPoolId] = useState(""); const [manualError, setManualError] = useState(null); + const [dRepId, setDRepId] = useState(AUTO_ABSTAIN_DREP); + const [drepError, setDrepError] = useState(null); useEffect(() => { if (!open) return; @@ -73,6 +81,8 @@ export default function AddStakeDialog({ setActionType(null); setManualPoolId(""); setManualError(null); + setDRepId(AUTO_ABSTAIN_DREP); + setDrepError(null); let cancelled = false; getProvider(network) .get(`/accounts/${stakeAddress}`) @@ -80,7 +90,7 @@ export default function AddStakeDialog({ 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"); @@ -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) { @@ -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); } @@ -221,16 +267,50 @@ export default function AddStakeDialog({ commitWithPool(poolHex)} /> )} + + {accountState !== null && canAddDrep && ( +
+ +
+ { + setDRepId(event.target.value); + setDrepError(null); + }} + /> + +
+ {drepError && ( +

+ {drepError} +

+ )} +
+ )} - {actionType === "deregister" && ( + {(actionType === "register" || actionType === "deregister") && + availableActions.includes(actionType) && ( diff --git a/src/components/pages/wallet/build/index.tsx b/src/components/pages/wallet/build/index.tsx index 59ef4f87..a6e9f0f7 100644 --- a/src/components/pages/wallet/build/index.tsx +++ b/src/components/pages/wallet/build/index.tsx @@ -317,9 +317,7 @@ export default function PageBuild() { const addStakeDisabledReason = externalSourceReason ?? (stakeCtx - ? draft.certificates.length > 0 - ? "The draft already has a staking action" - : undefined + ? undefined : multisigWalletLoading ? "Loading wallet…" : "This wallet has no staking identity"); diff --git a/src/components/pages/wallet/build/inspector/certificate-editor.tsx b/src/components/pages/wallet/build/inspector/certificate-editor.tsx index afc47668..622f76a7 100644 --- a/src/components/pages/wallet/build/inspector/certificate-editor.tsx +++ b/src/components/pages/wallet/build/inspector/certificate-editor.tsx @@ -22,6 +22,7 @@ const CERT_KIND_LABELS: Record = { RegisterStake: "Stake Registration", DelegateStake: "Stake Delegation", DeregisterStake: "Stake Deregistration", + VoteDelegation: "Vote Delegation", }; /** @@ -94,6 +95,18 @@ export default function CertificateEditor({ {getFirstAndLast(certificate.originalStakeAddress, 10, 6)} )} + {certificate.kind === "VoteDelegation" && ( + + {certificate.dRepId + ? certificate.dRepId.length <= 24 + ? certificate.dRepId + : getFirstAndLast(certificate.dRepId, 12, 6) + : "No DRep id"} + + )} {certificate.kind === "DelegateStake" && (
cert.kind === "VoteDelegation" && !cert.dRepId, + ) + ) { + throw new Error("Vote delegation certificate has no DRep id"); + } let selectedUtxos: UTxO[]; if (draft.utxoSelection.mode === "manual") { @@ -150,6 +158,14 @@ export function applyDraftToTxBuilder( case "DeregisterStake": txBuilder.deregisterStakeCertificate(ctx.stakeRewardAddress!); break; + case "VoteDelegation": + txBuilder.voteDelegationCertificate( + cert.dRepId === AUTO_ABSTAIN_DREP + ? { alwaysAbstain: null } + : { dRepId: cert.dRepId! }, + ctx.stakeRewardAddress!, + ); + break; } txBuilder.certificateScript(ctx.stakeScriptCbor!); } diff --git a/src/lib/tx-draft/validate.ts b/src/lib/tx-draft/validate.ts index 0450aa36..9cc967a0 100644 --- a/src/lib/tx-draft/validate.ts +++ b/src/lib/tx-draft/validate.ts @@ -1,6 +1,7 @@ import { deserializeAddress } from "@meshsdk/core"; import type { TxDraft } from "@/types/tx-draft"; +import { isValidDrepId } from "@/lib/tx-draft/drep"; import { normalizePoolIdForDelegation } from "@/utils/normalizePoolId"; import { materializeOutputAssets, @@ -26,6 +27,8 @@ export type DraftIssueCode = | "cert-delegate-unregistered" | "cert-already-registered" | "cert-deregister-unregistered" + | "cert-vote-unregistered" + | "cert-drep-missing" | "vote-drep-unregistered" | "source-address-missing" | "source-address-invalid" @@ -243,20 +246,31 @@ export function validateDraft( // Defensive: the inspector normalizes pool ids on entry, but a loaded tx // may carry a pool id that didn't normalize (kept raw by txJsonToDraft). for (const cert of draft.certificates) { - if (cert.kind !== "DelegateStake") continue; - let valid = false; - try { - valid = !!cert.poolId && !!normalizePoolIdForDelegation(cert.poolId); - } catch { - valid = false; + if (cert.kind === "DelegateStake") { + let valid = false; + try { + valid = !!cert.poolId && !!normalizePoolIdForDelegation(cert.poolId); + } catch { + valid = false; + } + if (!valid) { + issues.push({ + level: "error", + code: "cert-pool-missing", + message: "Delegation certificate has no valid stake pool id.", + }); + break; // one summary issue is enough + } } - if (!valid) { - issues.push({ - level: "error", - code: "cert-pool-missing", - message: "Delegation certificate has no valid stake pool id.", - }); - break; // one summary issue is enough + if (cert.kind === "VoteDelegation") { + if (!cert.dRepId || !isValidDrepId(cert.dRepId)) { + issues.push({ + level: "error", + code: "cert-drep-missing", + message: "Vote delegation needs an Auto-Abstain or drep1... id.", + }); + break; + } } } @@ -314,6 +328,14 @@ export function validateDraft( "The wallet's stake credential is not registered on chain — include a RegisterStake certificate (2 ADA deposit) before DelegateStake.", }); } + if (seenKinds.has("VoteDelegation") && !registersHere) { + issues.push({ + level: "error", + code: "cert-vote-unregistered", + message: + "Vote delegation needs a registered stake credential. Add Register stake credential in this transaction (2 ADA deposit). That does not delegate to a stake pool.", + }); + } if (seenKinds.has("DeregisterStake") && !registersHere) { issues.push({ level: "error", diff --git a/src/lib/tx-review/spec.ts b/src/lib/tx-review/spec.ts index 1948235c..ccbd7938 100644 --- a/src/lib/tx-review/spec.ts +++ b/src/lib/tx-review/spec.ts @@ -36,6 +36,7 @@ export const CERTIFICATE_KINDS: readonly DraftCertificateKind[] = [ const CERTIFICATE_ORDER: Record = { RegisterStake: 0, DelegateStake: 1, + VoteDelegation: 1, DeregisterStake: 2, }; export const VOTE_KINDS: readonly DraftVoteKind[] = ["Yes", "No", "Abstain"]; diff --git a/src/types/tx-draft.ts b/src/types/tx-draft.ts index ee368442..168b670d 100644 --- a/src/types/tx-draft.ts +++ b/src/types/tx-draft.ts @@ -58,12 +58,13 @@ export type DraftVote = { export type DraftCertificateKind = | "RegisterStake" | "DelegateStake" - | "DeregisterStake"; + | "DeregisterStake" + | "VoteDelegation"; /** - * A staking certificate, either loaded from an existing pending transaction - * or created in the builder. Loaded certs (no `origin`) can only change - * their delegation pool and can't be removed individually: a + * A staking or vote-delegation certificate, either loaded from an existing + * pending transaction or created in the builder. Loaded certs (no `origin`) + * can only change their delegation pool and can't be removed individually: a * register/delegate pair must stay intact, and a lone delegation cert is the * whole point of its transaction. User-created certs are removable, but a * register+delegate pair (shared `pairId`) is removed atomically. The reward @@ -75,6 +76,11 @@ export type DraftCertificate = { kind: DraftCertificateKind; /** Canonical bech32 pool id (pool1...); present only on DelegateStake. */ poolId?: string; + /** + * Governance DRep id for VoteDelegation. "Always Abstain" is the protocol + * Auto-Abstain credential; any other value is a drep1... id. + */ + dRepId?: string; /** stakeKeyAddress as stored in the loaded txJson — provenance only. */ originalStakeAddress?: string; /** Present only on certs created in the builder; absent = loaded. */ diff --git a/src/utils/token-flow/certificates.ts b/src/utils/token-flow/certificates.ts index c13730dd..0c8f4edc 100644 --- a/src/utils/token-flow/certificates.ts +++ b/src/utils/token-flow/certificates.ts @@ -137,6 +137,17 @@ export function draftCertificateToBadge( ...(poolName ? { title: poolName } : {}), }; } + case "VoteDelegation": + return { + kind: "certificate", + label: "Vote Delegation", + detail: cert.dRepId + ? cert.dRepId.length <= 24 + ? cert.dRepId + : getFirstAndLast(cert.dRepId) + : undefined, + color: "text-indigo-500 dark:text-indigo-400", + }; } }