Skip to content

Staking & delegation

Staking is where most Cardano applications first meet certificates. A certificate is not a payment; it is an instruction to the ledger that changes the state of a stake address. QuickTx builds them the same way it builds payments — as calls on Tx — but they carry one extra requirement you have to get right: which keys must sign.

By the end of this page you will have registered a stake address, delegated it to a pool, withdrawn rewards, and deregistered it to reclaim the deposit.

Before you start: finish Your first transaction. You need a funded account on Preview or a local devnet. On a default Yaci DevKit devnet, you can delegate to its pool, pool1wvqhvyrgwch4jq9aa84hc8q4kzvyq2z3xr6mpafkqmx9wce39zy.

Four operations, in order:

  1. Register the stake address — pays a refundable deposit (2 ADA on mainnet)
  2. Delegate it to a stake pool — takes effect two epochs later
  3. Withdraw rewards as they accumulate, which also needs a vote delegation
  4. Deregister when you are done — refunds the deposit

Steps 1 and 2 are usually done in a single transaction.

Every Account carries both a payment and a staking credential:

Account account = Account.createFromMnemonic(Networks.preview(), mnemonic);
account.baseAddress(); // addr_test1... — holds funds, pays fees
account.stakeAddress(); // stake_test1... — accrues rewards, delegates

The base address already embeds the staking credential — that is what makes it a base address rather than an enterprise address. Funds sitting at your base address are what counts toward your delegated stake; you do not move ADA to the stake address.

Do both in one transaction — there is no reason to pay two fees:

Tx tx = new Tx()
.registerStakeAddress(account.baseAddress())
.delegateTo(account.baseAddress(), poolId)
.from(account.baseAddress());
TxResult result = new QuickTxBuilder(backendService)
.compose(tx)
.withSigner(SignerProviders.signerFrom(account))
.withSigner(SignerProviders.stakeKeySignerFrom(account))
.completeAndWait();

poolId is the pool’s bech32 id (pool1...). You can pass a base address to registerStakeAddress and delegateTo — the library extracts the staking credential from it.

OperationPayment keyStake key
registerStakeAddressYesNo
delegateToYes (fees)Yes
delegateVotingPowerToYes (fees)Yes
withdrawYes (fees)Yes
deregisterStakeAddressYes (fees)Yes

When several stake addresses are involved, add one stakeKeySignerFrom(...) per account.

Delegation is not immediate, and this surprises people:

EpochWhat happens
nYou submit the delegation certificate
n + 1Stake snapshot is taken
n + 2Your stake is active for the pool
n + 4First rewards for that stake appear, at the start of the epoch

So roughly 15–20 days on mainnet before the first reward. Preview’s epochs last one day. On a local devnet they take minutes, or less if you set a shorter epoch length, which is why a devnet is the right place to test a staking flow end to end.

The ledger won’t let a key-based stake address withdraw until it has delegated its voting power. It rejects the transaction with ConwayWdrlNotDelegatedToDRep, even when the amount is zero. If you don’t want to choose a DRep, delegate to the built-in “always abstain” option. You only need to do this once:

Tx voteDelegation = new Tx()
.delegateVotingPowerTo(account.baseAddress(), DRep.abstain())
.from(account.baseAddress());
new QuickTxBuilder(backendService)
.compose(voteDelegation)
.withSigner(SignerProviders.signerFrom(account))
.withSigner(SignerProviders.stakeKeySignerFrom(account))
.completeAndWait();

Withdrawals must take the entire reward balance — the ledger has no partial withdrawal:

AccountInformation info = backendService.getAccountService()
.getAccountInformation(account.stakeAddress())
.getValue();
// withdrawableAmount is a lovelace string on AccountInformation.
BigInteger rewardBalance = new BigInteger(info.getWithdrawableAmount());
Tx tx = new Tx()
.withdraw(account.stakeAddress(), rewardBalance)
.from(account.baseAddress());
TxResult result = new QuickTxBuilder(backendService)
.compose(tx)
.withSigner(SignerProviders.signerFrom(account))
.withSigner(SignerProviders.stakeKeySignerFrom(account))
.completeAndWait();

Query the balance immediately before building. If the amount in the transaction does not match the reward balance exactly at the moment of validation, the transaction fails — so a value you cached earlier, or one that crossed an epoch boundary, will be rejected.

To send the rewards somewhere other than the change address, use the three-argument form: withdraw(stakeAddress, amount, receiverAddress).

Deregistration refunds the deposit and stops delegation:

Tx tx = new Tx()
.deregisterStakeAddress(account.baseAddress())
.from(account.baseAddress());
TxResult result = new QuickTxBuilder(backendService)
.compose(tx)
.withSigner(SignerProviders.signerFrom(account))
.withSigner(SignerProviders.stakeKeySignerFrom(account))
.completeAndWait();

Withdraw any outstanding rewards first. Deregistering with a non-zero reward balance is rejected.

In the Conway era a stake address has two independent delegations:

  • delegateTo(address, poolId) — block-production stake, earns rewards
  • delegateVotingPowerTo(address, drep) — governance voting power

Delegating to a pool does not give your voting power to anyone, and delegating voting power to a DRep does not change which pool you stake with. The one link is that withdrawing rewards needs a vote delegation. See Governance API for the voting side.

SymptomCause
Missing witness / MissingVKeyWitnessesUTXOWNo stakeKeySignerFrom(...) on a certificate that needs it
StakeKeyRegisteredDELEGAddress already registered — skip registration and just delegate
StakeKeyNotRegisteredDELEGDelegating before registering, or after deregistering
ConwayWdrlNotDelegatedToDRepThe stake address hasn’t delegated its voting power; see Withdraw rewards
ConwayIncompleteWithdrawalsAmount does not exactly equal the current reward balance
Delegated but no rewardsFewer than 4 epochs have passed, or the pool produced no blocks