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
157 changes: 35 additions & 122 deletions package-lock.json

Large diffs are not rendered by default.

62 changes: 59 additions & 3 deletions src/__tests__/tx-builders/stakingCert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type CertCall =
| { type: "register"; address: string }
| { type: "deregister"; address: string }
| { type: "delegate"; address: string; poolHex: string }
| { type: "voteDelegation"; address: string; dRep: unknown }
| { type: "withdrawal"; address: string; amount: string };

function createCertBuilderMock() {
Expand All @@ -31,6 +32,10 @@ function createCertBuilderMock() {
calls.push({ type: "delegate", address, poolHex });
return builder;
},
voteDelegationCertificate: (dRep: unknown, address: string) => {
calls.push({ type: "voteDelegation", address, dRep });
return builder;
},
certificateScript: (_scriptCbor: string) => builder,
withdrawal: (address: string, amount: string) => {
calls.push({ type: "withdrawal", address, amount });
Expand Down Expand Up @@ -79,11 +84,12 @@ describe("buildStakingCertificateActions", () => {
rewardAddress: REWARD_ADDRESS,
stakingScript: STAKING_SCRIPT_CBOR,
poolHex: POOL_HEX,
dRep: "Always Abstain",
});

actions.delegate.execute();

expect(calls).toHaveLength(1);
expect(calls).toHaveLength(2);
expect(calls[0]).toMatchObject({ type: "delegate", address: REWARD_ADDRESS, poolHex: POOL_HEX });
});

Expand All @@ -94,11 +100,12 @@ describe("buildStakingCertificateActions", () => {
rewardAddress: REWARD_ADDRESS,
stakingScript: STAKING_SCRIPT_CBOR,
poolHex: POOL_HEX,
dRep: "Always Abstain",
});

actions.register_and_delegate.execute();

expect(calls).toHaveLength(2);
expect(calls).toHaveLength(3);
expect(calls[0]).toMatchObject({ type: "register", address: REWARD_ADDRESS });
expect(calls[1]).toMatchObject({ type: "delegate", address: REWARD_ADDRESS, poolHex: POOL_HEX });
});
Expand All @@ -110,6 +117,7 @@ describe("buildStakingCertificateActions", () => {
rewardAddress: REWARD_ADDRESS,
stakingScript: STAKING_SCRIPT_CBOR,
poolHex: "",
dRep: "Always Abstain",
});

expect(() => actions.delegate.execute()).not.toThrow();
Expand Down Expand Up @@ -157,11 +165,12 @@ describe("buildStakingActionConfigs", () => {
stakingScript: STAKING_SCRIPT_CBOR,
poolHex: POOL_HEX,
rewards: "0",
dRep: "Always Abstain",
});

configs.registerAndDelegate.execute();

expect(calls).toHaveLength(2);
expect(calls).toHaveLength(3);
expect(calls[0]).toMatchObject({ type: "register", address: REWARD_ADDRESS });
expect(calls[1]).toMatchObject({ type: "delegate", address: REWARD_ADDRESS, poolHex: POOL_HEX });
});
Expand All @@ -184,3 +193,50 @@ describe("buildStakingActionConfigs", () => {
}
});
});

describe("staking requires a DRep", () => {
const base = {
rewardAddress: REWARD_ADDRESS,
stakingScript: STAKING_SCRIPT_CBOR,
poolHex: POOL_HEX,
};

it("delegate throws without a DRep", () => {
const { builder } = createCertBuilderMock();
const actions = buildStakingCertificateActions({ txBuilder: builder as never, ...base });
expect(() => actions.delegate.execute()).toThrow(/requires a DRep/);
expect(() => actions.register_and_delegate.execute()).toThrow(/requires a DRep/);
});

it("delegate emits pool then vote delegation (abstain / no-confidence)", () => {
for (const [dRep, expected] of [
["Always Abstain", { alwaysAbstain: null }],
["Always No Confidence", { alwaysNoConfidence: null }],
] as const) {
const { builder, calls } = createCertBuilderMock();
buildStakingCertificateActions({ txBuilder: builder as never, ...base, dRep }).delegate.execute();
expect(calls.map((c) => c.type)).toEqual(["delegate", "voteDelegation"]);
expect(calls[1]).toMatchObject({ dRep: expected });
}
});

it("register_and_delegate emits register, delegate, vote delegation", () => {
const { builder, calls } = createCertBuilderMock();
buildStakingCertificateActions({
txBuilder: builder as never,
...base,
dRep: "Always Abstain",
}).register_and_delegate.execute();
expect(calls.map((c) => c.type)).toEqual(["register", "delegate", "voteDelegation"]);
});

it("skips the vote delegation when the stake credential already has a DRep", () => {
const { builder, calls } = createCertBuilderMock();
buildStakingCertificateActions({
txBuilder: builder as never,
...base,
dRepAlreadyDelegated: true,
}).delegate.execute();
expect(calls.map((c) => c.type)).toEqual(["delegate"]);
});
});
36 changes: 26 additions & 10 deletions src/components/pages/wallet/info/signers/card-show-signers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,38 @@ export default function ShowSigners({ appWallet }: ShowSignersProps) {

const { mutate: updateWalletVerifiedList } =
api.wallet.updateWalletVerifiedList.useMutation({
onSuccess: async () => {
toast({
title: "Wallet verified",
description: "You have successfully verified this wallet",
duration: 5000,
});
onSuccess: async (updated) => {
// The server only ever records the signed-in session address, and
// returns success even when that isn't the connected wallet's address
// (wallet switched after sign-in). Don't claim success in that case.
if (userAddress && !updated.verified.includes(userAddress)) {
toast({
title: "Verification not recorded",
description:
"Your signed-in session is for a different address than the connected wallet. Re-authorize with the connected wallet and try again.",
variant: "destructive",
duration: 8000,
});
} else {
toast({
title: "Wallet verified",
description: "You have successfully verified this wallet",
duration: 5000,
});
}
void ctx.wallet.getWallet.invalidate({
address: userAddress,
walletId: appWallet.id,
});
},
onError: (e) => {
console.error(e);
toast({
title: "Verification failed",
description: e.message,
variant: "destructive",
duration: 6000,
});
},
});

Expand Down Expand Up @@ -146,12 +165,9 @@ export default function ShowSigners({ appWallet }: ShowSignersProps) {
const result = await checkSignature(nonce, signature);

if (result) {
const _verified = appWallet.verified;
_verified.push(userAddress);

updateWalletVerifiedList({
walletId: appWallet.id,
verified: _verified,
verified: [...appWallet.verified, userAddress],
});
}
}
Expand Down
11 changes: 10 additions & 1 deletion src/components/pages/wallet/staking/StakingActions/stake.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default function StakeButton({
utxos,
network,
poolHex,
dRep,
action,
}: {
stakingInfo: StakingInfo;
Expand All @@ -28,10 +29,12 @@ export default function StakeButton({
utxos: UTxO[];
network: number;
poolHex: string;
dRep?: string;
action: StakingAction;
}) {
const { newTransaction } = useTransaction();
const [loading, setLoading] = useState(false);
const needsDRep = action === "delegate" || action === "registerAndDelegate";

async function handleStake() {
setLoading(true);
Expand Down Expand Up @@ -65,6 +68,8 @@ export default function StakeButton({
stakingScript,
poolHex,
rewards: stakingInfo.rewards,
dRep,
dRepAlreadyDelegated: !!stakingInfo.dRepId,
});
const actionConfig = actionConfigs[action];

Expand Down Expand Up @@ -122,7 +127,11 @@ export default function StakeButton({
}

return (
<Button variant="outline" onClick={handleStake} disabled={loading}>
<Button
variant="outline"
onClick={handleStake}
disabled={loading || (needsDRep && !dRep && !stakingInfo.dRepId)}
>
{loading ? <Loader className="mr-2 h-4 w-4 animate-spin" /> : null}
{action.charAt(0).toUpperCase() + action.slice(1)}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ export default function StakingActionCard({
mWallet,
network,
poolHex,
dRep,
}: {
stakingInfo: StakingInfo;
appWallet: Wallet;
mWallet: MultisigWallet;
network: number;
poolHex: string;
dRep?: string;
}) {
const [selectedUtxos, setSelectedUtxos] = useState<UTxO[]>([]);
const [manualSelected, setManualSelected] = useState<boolean>(false);
Expand All @@ -34,6 +36,7 @@ export default function StakingActionCard({
utxos={selectedUtxos}
network={network}
poolHex={poolHex}
dRep={dRep}
action="registerAndDelegate"
/>
)}
Expand All @@ -46,6 +49,7 @@ export default function StakingActionCard({
utxos={selectedUtxos}
network={network}
poolHex={poolHex}
dRep={dRep}
action="delegate"
/>
<StakeButton
Expand All @@ -55,6 +59,7 @@ export default function StakingActionCard({
utxos={selectedUtxos}
network={network}
poolHex={poolHex}
dRep={dRep}
action="deregister"
/>
</>
Expand All @@ -67,6 +72,7 @@ export default function StakingActionCard({
utxos={selectedUtxos}
network={network}
poolHex={poolHex}
dRep={dRep}
action="withdrawal"
/>
)}
Expand Down
48 changes: 47 additions & 1 deletion src/components/pages/wallet/staking/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { getProvider } from "@/utils/get-provider";
import StakingInfoCard, { StakingInfo } from "./stakingInfoCard";
import PoolSelector from "./poolSelector";
import useAppWallet from "@/hooks/useAppWallet";
import { AUTO_ABSTAIN_DREP } from "@/lib/tx-draft/drep";
import { NO_CONFIDENCE_DREP, isValidStakingDRep } from "@/utils/stakingCertificates";

type DRepMode = "abstain" | "noConfidence" | "custom";

export default function PageStaking() {
const { multisigWallet } = useMultisigWallet();
Expand All @@ -19,6 +23,19 @@ export default function PageStaking() {
const [stakingInfo, setStakingInfo] = useState<StakingInfo | null>(null);
const [selectedPoolHex, setSelectedPoolHex] = useState<string | null>(null);

const [dRepMode, setDRepMode] = useState<DRepMode>("abstain");
const [customDRep, setCustomDRep] = useState("");

// Staking always pairs the pool with a DRep; undefined keeps the actions disabled.
const dRep: string | undefined =
dRepMode === "abstain"
? AUTO_ABSTAIN_DREP
: dRepMode === "noConfidence"
? NO_CONFIDENCE_DREP
: isValidStakingDRep(customDRep.trim())
? customDRep.trim()
: undefined;

const address = multisigWallet?.getStakeAddress();

useEffect(() => {
Expand All @@ -32,6 +49,7 @@ export default function PageStaking() {
balance: data.controlled_amount,
rewards: data.rewards_sum,
withdrawals: data.withdrawals_sum,
dRepId: data.drep_id ?? null,
});
})
.catch((err) => {
Expand Down Expand Up @@ -74,10 +92,38 @@ export default function PageStaking() {
</p>
</div>
)}
{!stakingInfo.dRepId && (
<div className="mt-4 p-4">
<label className="mb-1 block text-sm font-medium text-gray-700">
Governance vote delegation (DRep) — required when staking:
</label>
<select
value={dRepMode}
onChange={(e) => setDRepMode(e.target.value as DRepMode)}
className="w-full rounded border px-3 py-2 text-sm"
>
<option value="abstain">Auto-Abstain</option>
<option value="noConfidence">No-Confidence</option>
<option value="custom">Choose a DRep (drep1...)</option>
</select>
{dRepMode === "custom" && (
<input
type="text"
value={customDRep}
onChange={(e) => setCustomDRep(e.target.value)}
placeholder="drep1..."
className="mt-2 w-full rounded border px-3 py-2 text-sm"
/>
)}
{dRepMode === "custom" && !dRep && customDRep && (
<p className="mt-1 text-xs text-red-600">Not a valid DRep id.</p>
)}
</div>
)}
<br/>
{stakingInfo && appWallet && multisigWallet && selectedPoolHex && (
<div className="mt-4 p-4">
<StakingActionCard stakingInfo={stakingInfo} appWallet={appWallet} mWallet={multisigWallet} network={network} poolHex={selectedPoolHex}/>
<StakingActionCard stakingInfo={stakingInfo} appWallet={appWallet} mWallet={multisigWallet} network={network} poolHex={selectedPoolHex} dRep={dRep}/>
</div>
)}
<br/>
Expand Down
2 changes: 2 additions & 0 deletions src/components/pages/wallet/staking/stakingInfoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface StakingInfo {
balance: string;
rewards: string;
withdrawals: string;
/** Current on-chain vote delegation of the stake credential, if any. */
dRepId?: string | null;
}

export default function StakingInfoCard({ stakingInfo }: { stakingInfo: StakingInfo }) {
Expand Down
1 change: 1 addition & 0 deletions src/pages/api/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro
- `address`: string (required; must match JWT)
- `action`: `"register"` | `"deregister"` | `"delegate"` | `"register_and_delegate"` (required)
- `poolId`: string (required for `delegate` and `register_and_delegate`; bech32 `pool1...` or 56-character hex pool id)
- `dRep`: string (optional; `"Always Abstain"` (default), `"Always No Confidence"` or a `drep1...` id). Delegation always adds a vote delegation to this DRep.
- `utxoRefs`: `{ txHash: string; outputIndex: number }[]` (required)
- `description`: string (optional; defaults to a short label for the action)
- **Response**: Same as `addTransaction` — either a pending `Transaction` row (**201**) when multiple signatures are required, or the immediate **`submitTx`** result when the wallet submits in one step (single signer / `type === "any"`).
Expand Down
Loading
Loading