tortuga::stake_router
This module is the main user facing module of the Tortuga protocol.
It handles:
- APT -> tAPT transactions,
- issuing of
Tickets
and claiming them for tAPT -> APT, and, - permissionless staking/withdrawal from validators according to their scores
use 0x1::account;
use 0x1::aptos_coin;
use 0x1::coin;
use 0x1::error;
use 0x1::event;
use 0x1::option;
use 0x1::signer;
use 0x1::string;
use 0x1::timestamp;
use 0x1::vector;
use 0xc0ded0c0::math;
use 0xc0ded0c0::validator_router;
use 0xc0ded0c0::validator_states;
use 0xc0ded0c1::permissions;
use 0xc0ded0c2::staked_aptos_coin;
use 0xc0ded0c2::tortuga_governance;
Struct which holds the current state of the protocol, including the storage for the protocol fee.
struct StakingStatus has key
protocol_fee:
coin::Coin
<
staked_aptos_coin::StakedAptosCoin
>
Stores the protocol commission collected so farcommission: u64
The rate at which protocol charges commission.community_rebate: u64
When a user calls unstake
, this much amount is taken from the user and distributed among all the current users of the protocol.total_claims_balance: u128
Total balance of all the tickets issues since genesis.total_claims_balance_cleared: u128
Total balance of all the redeemed tickets since genesis.cooldown_period: u64
time that a user must wait between ticket issuance and ticket redemption, and time that must elapse after contract deployment before unstake
is available.deployment_time: u64
timestamp of the contract deployment.commission_exempt_amount: u64
principal over which protocol's commission is calculated.min_transaction_amount: u64
minimum amount to delegate. Also, used for clearing tAPT dust.increase_stake_in_validator_events:
event::EventHandle
<
stake_router::IncreaseStakeInValidatorEvent
>
Event handle for increasing stake in a validator.unlock_from_validator_events:
event::EventHandle
<
stake_router::UnlockFromValidatorEvent
>
Event handle for unlocking stake in a validator.Struct which holds handles for events related to staking, unstaking, and claiming tickets. It is saved with the delegator's accounts.
struct EventStore has key
stake_events:
event::EventHandle
<
stake_router::StakeEvent
>
Stores StakeEvents
for the delegatorunstake_events:
event::EventHandle
<
stake_router::UnstakeEvent
>
Stores UnstakeEvents
for the delegatorclaim_events:
event::EventHandle
<
stake_router::ClaimEvent
>
Stores ClaimEvents
for the delegatorCapabilities associated with tAPT coin
struct StakedAptosCapabilities has key
mint_cap:
coin::MintCapability
<
staked_aptos_coin::StakedAptosCoin
>
Mint capabilityfreeze_cap:
coin::FreezeCapability
<
staked_aptos_coin::StakedAptosCoin
>
Freeze capabilityburn_cap:
coin::BurnCapability
<
staked_aptos_coin::StakedAptosCoin
>
Burn capabilityThe
ticket
which is issued when a user unstakes.struct Ticket has store
id: u128
unique id
, used to ensure first-in-first-out for claimsamount: u64
amount in APT that will be redeemedtimestamp
: u64
timestamp when the ticket was issuedA user can have several tickets which are stored in their ticket store
struct TicketStore has key
Event emitted when a user stakes
struct StakeEvent has drop, store
delegator:
address
account addressamount: u64
amount staked in APTt_apt_coins: u64
tAPT coins receivedtimestamp
: u64
timestamp when the event happenedEvent emitted when a user unstakes
struct UnstakeEvent has drop, store
delegator:
address
account addressamount: u64
amount unstaked in APTt_apt_coins: u64
tAPT coins burnedtimestamp
: u64
timestamp when the event happenedEvent emitted when a user claims a ticket
struct ClaimEvent has drop, store
delegator:
address
Account addressamount: u64
Amount claimed in APTticket_index: u64
Index of the ticket in the TicketStore
timestamp
: u64
timestamp when the event happenedEvent emitted when a user increases stake in a validator
struct IncreaseStakeInValidatorEvent has drop, store
managed_pool_address:
address
Address of the validator's ManagedStakingPoolamount: u64
The increase in stake in APTtimestamp
: u64
timestamp when the event happenedEvent emitted when a user unlocks funds from a validator
struct UnlockFromValidatorEvent has drop, store
managed_pool_address:
address
Address of the validator's ManagedStakingPoolamount: u64
The amount of funds unlocked in APTtimestamp
: u64
timestamp when the event happenedWhen a non-protocol user tries to do any permissioned operation.
const EPERMISSION_DENIED: u64 = 6;
The normalizer for the commission rates.
const COMMISSION_NORMALIZER: u64 = 1000000;
When the amount in APT is too small for the operation.
const EAMOUNT_TOO_SMALL: u64 = 9;
When the commission being set is too high.
const ECOMMISSION_TOO_HIGH: u64 = 11;
When the new withdrawal fee is too high.
const ECOMMUNITY_REBATE_TOO_HIGH: u64 = 15;
When the cooldown period being set is too long.
const ECOOLDOWN_TOO_LONG: u64 = 12;
When a ticket cannot be claimed because the funds are not available.
const EFUND_NOT_AVAILABLE_YET: u64 = 5;
When a user tries to increase too much stake in a validator or unlock too much from a validator.
const EINVALID_AMOUNT: u64 = 10;
When a user tries to unstake a non-existent ticket.
const EINVALID_TICKET_INDEX: u64 = 8;
When the new min transaction amount is too high.
const EMIN_TRANSACTION_AMOUNT_TOO_HIGH: u64 = 14;
When a user tries to claim a ticket without ever having unstaked.
const ENO_REMAINING_CLAIMS: u64 = 4;
When the cooldown for a ticket has not elapsed.
const ETICKET_NOT_READY_YET: u64 = 3;
When the calculation of APT from shares fails because of incorrect arguments
const ETOO_MANY_SHARES: u64 = 2;
When the
unstake
operation is called before the cooldown period since contract deployment has elapsed.const EUNSTAKE_NOT_READY_YET: u64 = 13;
The maximum withdrawal fee amount
const MAX_COMMUNITY_REBATE: u64 = 30000;
The maximum cooldown period in seconds.
const MAX_COOLDOWN_PERIOD: u64 = 1209600;
The maximum minimum transaction amount
const MAX_MIN_TRANSACTION_AMOUNT: u64 = 100000000000;
Returns the total tAPT minted.
public fun get_t_apt_supply(): u64
Returns the total value locked with the protocol in APT (legacy name).
public fun get_total_value(): u64
public fun get_total_value(): u64 acquires StakingStatus {
let staking_status = borrow_global<StakingStatus>(@tortuga_governance);
let unclaimed_balance =
staking_status.total_claims_balance -
staking_status.total_claims_balance_cleared;
validator_router::get_total_balance() - (unclaimed_balance as u64)
}
Returns how much APT can be staked with validators.
public fun current_total_stakable_amount(): u64
public fun current_total_stakable_amount(): u64 acquires StakingStatus {
let staking_status = borrow_global<StakingStatus>(@tortuga_governance);
let unclaimed_balance = (
(
staking_status.total_claims_balance -
staking_status.total_claims_balance_cleared
) as u64
);
let reserve_balance = validator_router::get_reserve_balance();
let total_unlocking_balance =
validator_states::get_total_unlocking_balance(@tortuga_governance);
if (reserve_balance + total_unlocking_balance < unclaimed_balance) {
0
} else {
reserve_balance + total_unlocking_balance - unclaimed_balance
}
}
This function calculates how much needs to be unlocked from the validators, to be able to process unclaimed funds.
public fun current_deficit(): u64
public fun current_deficit(): u64 acquires StakingStatus {
let staking_status = borrow_global<StakingStatus>(@tortuga_governance);
let unclaimed_balance = (
(staking_status.total_claims_balance -
staking_status.total_claims_balance_cleared)
as u64
);
let reserve_balance = validator_router::get_reserve_balance();
let total_unlocking_balance =
validator_states::get_total_unlocking_balance(@tortuga_governance);
if (reserve_balance + total_unlocking_balance > unclaimed_balance) {
0
} else {
unclaimed_balance - (reserve_balance + total_unlocking_balance)
}
}
Returns the total number of tickets that
delegator
has.public fun get_num_tickets(delegator: address): u64
Returns the ticket at position
index
for delegator
as a 3-tuple of:id: u128
: uniqueid
, used to ensure first-in-first-out for claims,amount: u64
, amount in APT that will be redeemed, and,
- If
delegator
does not have a ticket atindex
.
public fun get_ticket(delegator: address, index: u64): (u128, u64, u64)
public fun get_ticket(
delegator: address,
index: u64
): (u128, u64, u64) acquires TicketStore {
assert!(
exists<TicketStore>(delegator),
error::invalid_argument(EINVALID_TICKET_INDEX)
);
let ticket_store = borrow_global<TicketStore>(delegator);
assert!(
index < vector::length(&ticket_store.tickets),
error::invalid_argument(EINVALID_TICKET_INDEX)
);
let Ticket {
id,
amount,
timestamp
} = vector::borrow(&ticket_store.tickets, index);
(*id, *amount, *timestamp)
}
This initializes the liquid staking protocol for
tortuga
with the given commission
, cooldown_period
and max_number_of_validators
.public fun initialize_tortuga_liquid_staking(tortuga_governance_deployer: &signer, commission: u64, cooldown_period: u64, max_number_of_validators: u64)
public entry fun initialize_tortuga_liquid_staking(
tortuga_governance_deployer: &signer,
commission: u64,
cooldown_period: u64,
max_number_of_validators: u64
) {
// Since this function initializes a coin whose type is declared in
// @tortuga_governance, this function can run successfully only if
// called via the governance address. This assert is a reminder for
// that.
assert!(
signer::address_of(tortuga_governance_deployer) == @tortuga_governance,
error::unauthenticated(EPERMISSION_DENIED)
);
assert!(
commission <= COMMISSION_NORMALIZER,
error::invalid_argument(ECOMMISSION_TOO_HIGH)
);
// initialize the staked aptos coin first
let (burn_cap, freeze_cap, mint_cap) =
coin::initialize<StakedAptosCoin>(
tortuga_governance_deployer,
string::utf8(b"Tortuga Staked APT"),
string::utf8(b"tAPT"),
8,
true,
);
move_to(tortuga_governance_deployer, StakedAptosCapabilities {
mint_cap: mint_cap,
freeze_cap: freeze_cap,
burn_cap: burn_cap,
});
move_to(tortuga_governance_deployer, StakingStatus {
protocol_fee: coin::zero<StakedAptosCoin>(),
commission: commission,
community_rebate: 0,
total_claims_balance: 0,
total_claims_balance_cleared: 0,
cooldown_period: cooldown_period,
deployment_time: timestamp::now_seconds(),
commission_exempt_amount: 0,
min_transaction_amount: 1000000, // 0.01 APT // tAPT
increase_stake_in_validator_events:
account::new_event_handle<IncreaseStakeInValidatorEvent>(
tortuga_governance_deployer
),
unlock_from_validator_events:
account::new_event_handle<UnlockFromValidatorEvent>(
tortuga_governance_deployer
),
});
// finally initialize the validator router
validator_router::initialize(tortuga_governance_deployer, max_number_of_validators);
// Initialize phase 0 of governance
permissions::initialize_permissions<TortugaGovernance>(tortuga_governance_deployer);
}
Set the commission that
tortuga
protocol charges to value
.public fun set_commission(tortuga_governance: &signer, value: u64)
public entry fun set_commission(
tortuga_governance: &signer,
value: u64
) acquires StakingStatus {
permissions::assert_authority<TortugaGovernance>(tortuga_governance);
let staking_status = borrow_global_mut<StakingStatus>(
@tortuga_governance
);
assert!(
value <= COMMISSION_NORMALIZER,
error::invalid_argument(ECOMMISSION_TOO_HIGH)
);
staking_status.commission = value;
}
Set the community rebate that
tortuga
users get to value
.- If the
value
is greater thanMAX_WITHDRAWAL_FEE
, i.e. 0.05%.
public fun set_community_rebate(tortuga_governance: &signer, value: u64)
public entry fun set_community_rebate(
tortuga_governance: &signer,
value: u64
) acquires StakingStatus {
permissions::assert_authority<TortugaGovernance>(tortuga_governance);
let staking_status = borrow_global_mut<StakingStatus>(
@tortuga_governance
);
assert!(
value <= MAX_COMMUNITY_REBATE,
error::invalid_argument(ECOMMUNITY_REBATE_TOO_HIGH)
);
staking_status.community_rebate = value;
}
Set wait period between ticket issuance and redemption.
public fun set_cooldown_period(tortuga_governance: &signer, value: u64)
public entry fun set_cooldown_period(
tortuga_governance: &signer,
value: u64
) acquires StakingStatus {
permissions::assert_authority<TortugaGovernance>(tortuga_governance);
let staking_status = borrow_global_mut<StakingStatus>(
@tortuga_governance
);
assert!(
value <= MAX_COOLDOWN_PERIOD,
error::invalid_argument(ECOOLDOWN_TOO_LONG)
);
staking_status.cooldown_period = value;
}
Set minimum delegation amount.
public fun set_min_transaction_amount(tortuga_governance: &signer, value: u64)
public entry fun set_min_transaction_amount(
tortuga_governance: &signer,
value: u64
) acquires StakingStatus {
permissions::assert_authority<TortugaGovernance>(tortuga_governance);
assert!(
value <= MAX_MIN_TRANSACTION_AMOUNT,
error::invalid_argument(EMIN_TRANSACTION_AMOUNT_TOO_HIGH)
);
let staking_status = borrow_global_mut<StakingStatus>(
@tortuga_governance
);
staking_status.min_transaction_amount = value;
}
This is the endpoint for a user who wants to stake
amount
APT and get tAPT in return. The coins are directly deposited to the user's account.- If the
amount
is less thanmin_transaction_amount
.
public fun stake(delegator: &signer, amount: u64)
public entry fun stake(
delegator: &signer,
amount: u64
) acquires StakingStatus, StakedAptosCapabilities, EventStore {
let staking_status = borrow_global<StakingStatus>(@tortuga_governance);
assert!(
amount >= staking_status.min_transaction_amount,
error::invalid_argument(EAMOUNT_TOO_SMALL)
);
let delegator_address = signer::address_of(delegator);
let coins_to_stake = coin::withdraw<AptosCoin>(delegator, amount);
register_for_t_apt(delegator);
let minted_t_apt_coin = stake_coins(coins_to_stake);
ensure_event_store(delegator);
let event_store = borrow_global_mut<EventStore>(
delegator_address
);
event::emit_event<StakeEvent>(
&mut event_store.stake_events,
StakeEvent {
delegator: delegator_address,
amount: amount,
t_apt_coins: coin::value<StakedAptosCoin>(&minted_t_apt_coin),
timestamp: timestamp::now_seconds(),
}
);
// send shares to the delegator
coin::deposit(delegator_address, minted_t_apt_coin);
}
Request to redeem APT by burn
t_apt_amount
tAPT.- If the
t_apt_amount
is less thanmin_transaction_amount
. - If the
t_apt_amount
is greater than the delegator's tAPT balance. - If
cooldown_period
has not passed since the contract's first deployment.
public fun unstake(delegator: &signer, t_apt_amount: u64)
public entry fun unstake(
delegator: &signer,
t_apt_amount: u64
) acquires
StakingStatus,
StakedAptosCapabilities,
TicketStore,
EventStore
{
let staking_status = borrow_global<StakingStatus>(@tortuga_governance);
assert!(
timestamp::now_seconds() >=
staking_status.cooldown_period +
staking_status.deployment_time,
EUNSTAKE_NOT_READY_YET,
);
// Check against minimum transaction account
let t_apt_supply = get_t_apt_supply();
assert!(
t_apt_amount >= staking_status.min_transaction_amount
|| t_apt_amount == t_apt_supply,
error::invalid_argument(EAMOUNT_TOO_SMALL)
);
let t_apt_coins_to_unstake = coin::withdraw(delegator, t_apt_amount);
let t_apt_remaining = coin::balance<StakedAptosCoin>(
signer::address_of(delegator)
);
// must charge protocol fee to be able to calculate shares value
// correctly
charge_protocol_fee_internal();
// calculate total worth
let total_worth = get_total_value();
// Need to borrow again because previous line also borrow this
// resource.
staking_status = borrow_global<StakingStatus>(@tortuga_governance);
// If too little t_apt_coins remain in the wallet, then unstake all
if (t_apt_remaining < staking_status.min_transaction_amount) {
coin::merge<StakedAptosCoin>(
&mut t_apt_coins_to_unstake,
coin::withdraw(delegator, t_apt_remaining)
);
t_apt_amount = t_apt_amount + t_apt_remaining;
};
let shares_value = calc_shares_to_value(
t_apt_amount,
total_worth,
t_apt_supply
);
// Distribute community rebate
// to the remaining (t_apt_supply - t_apt_amount)
// shares
if (t_apt_supply > t_apt_amount) {
let community_rebate = math::mul_div(
shares_value,
staking_status.community_rebate,
COMMISSION_NORMALIZER
);
shares_value = shares_value - community_rebate;
};
// burn the tAPT coins
let capabilities = borrow_global<StakedAptosCapabilities>(
@tortuga_governance
);
coin::burn<StakedAptosCoin>(
t_apt_coins_to_unstake,
&capabilities.burn_cap
);
// modify total claims balance, and commission exempt amount
let staking_status = borrow_global_mut<StakingStatus>(
@tortuga_governance
);
staking_status.total_claims_balance = (
staking_status.total_claims_balance + (shares_value as u128)
);
staking_status.commission_exempt_amount = (
staking_status.commission_exempt_amount - shares_value
);
// issue claim ticket
let delegator_address = signer::address_of(delegator);
if (!exists<TicketStore>(delegator_address)) {
move_to(delegator, TicketStore { tickets: vector::empty() });
};
let ticket_store = borrow_global_mut<TicketStore>(delegator_address);
let ticket_id = staking_status.total_claims_balance;
let now = timestamp::now_seconds();
vector::push_back(
&mut ticket_store.tickets,
Ticket {
id: ticket_id,
amount: shares_value,
timestamp: now
}
);
ensure_event_store(delegator);
let event_store = borrow_global_mut<EventStore>(
delegator_address
);
event::emit_event<UnstakeEvent>(
&mut event_store.unstake_events,
UnstakeEvent {
delegator: delegator_address,
amount: shares_value,
t_apt_coins: t_apt_amount,
timestamp: now
}
);
}
Users can claim their unstaked APTs using this function, after their funds have unlocked. A person who unstakes first will become eligible to claim first.
- If the
ticket_index
does not exist (anymore). - If the
cooldown_period
has not passed since the ticket's creation. - If the funds are still locked and not available to be claimed.
public fun claim(delegator: &signer, ticket_index: u64)
public entry fun claim(
delegator: &signer,
ticket_index: u64
) acquires TicketStore, StakingStatus, EventStore {
let delegator_address = signer::address_of(delegator);
assert!(
exists<TicketStore>(delegator_address),
error::not_found(ENO_REMAINING_CLAIMS)
);
// get claim ticket
let ticket_store = borrow_global_mut<TicketStore>(delegator_address);
assert!(
ticket_index < vector::length(&ticket_store.tickets),
error::invalid_argument(EINVALID_TICKET_INDEX)
);
let ticket = vector::remove(
&mut ticket_store.tickets,
ticket_index
);
// assert that ticket has cooled down
let staking_status = borrow_global_mut<StakingStatus>(
@tortuga_governance
);
assert!(
timestamp::now_seconds() - ticket.timestamp >
staking_status.cooldown_period,
error::invalid_argument(ETICKET_NOT_READY_YET)
);
// assert that the funds for this ticket has arrived
let reserve_balance = validator_router::get_reserve_balance();
assert!(
ticket.id <=
(reserve_balance as u128) +
staking_status.total_claims_balance_cleared,
error::unavailable(EFUND_NOT_AVAILABLE_YET)
);
// Pay aptos coins to the delegator
let coins_to_repay = validator_router::extract_coins_from_reserve(
ticket.amount
);
// The user may not have registered for aptos coins yet
if (!coin::is_account_registered<AptosCoin>(delegator_address)) {
coin::register<AptosCoin>(delegator);
};
coin::deposit<AptosCoin>(delegator_address, coins_to_repay);
// update staking status
staking_status.total_claims_balance_cleared = (
staking_status.total_claims_balance_cleared + (
ticket.amount as u128
)
);
ensure_event_store(delegator);
let event_store = borrow_global_mut<EventStore>(
delegator_address
);
event::emit_event<ClaimEvent>(
&mut event_store.claim_events,
ClaimEvent {
delegator: delegator_address,
amount: ticket.amount,
ticket_index: ticket_index,
timestamp: timestamp::now_seconds()
}
);
// burn the claim ticket
burn_ticket(ticket);
}
Increase the stake in the given
managed_pool_address
by amount
if there are coins to stake in the coin reserve of the protocol and the current score and stake in the validator allows for it.This allows permissionless staking into validators.
- If the
amount
is smaller than 1 APT. - If the validator balance will become more than the target delegation. See
validator_states
module for details.
public fun increase_stake_in_validator(managed_pool_address: address, amount: u64)
public entry fun increase_stake_in_validator(
managed_pool_address: address,
amount: u64
) acquires StakingStatus {
// Require the increase to be at least 1 APT
assert!(
amount >= 100000000,
error::invalid_argument(EAMOUNT_TOO_SMALL)
);
let required_tvl = get_total_value();
let target_delegation = validator_states::get_target_delegation(
@tortuga_governance,
managed_pool_address,
required_tvl
);
let validator_balance = validator_states::get_balance_at_last_update(
@tortuga_governance,
managed_pool_address
);
assert!(
target_delegation >= validator_balance + amount &&
amount <= current_total_stakable_amount(),
error::invalid_argument(EINVALID_AMOUNT)
);
validator_router::delegate(managed_pool_address, amount);
let staking_status = borrow_global_mut<StakingStatus>(
@tortuga_governance
);
event::emit_event<IncreaseStakeInValidatorEvent>(
&mut staking_status.increase_stake_in_validator_events,
IncreaseStakeInValidatorEvent {
managed_pool_address: managed_pool_address,
amount: amount,
timestamp: timestamp::now_seconds()
}
);
}
This function can be called if the total free balance is not enough to settle outstanding tickets. Anyone can call this function to unlock from the earliest unlocking validator, if current deficit is positive.