Links

tortuga::stake_router

Module 0xc0ded0c0::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
The validators will interface with the protocol through the validator_router module.
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;

Resource StakingStatus

Struct which holds the current state of the protocol, including the storage for the protocol fee.
struct StakingStatus has key
Fields
protocol_fee: coin::Coin<staked_aptos_coin::StakedAptosCoin>Stores the protocol commission collected so farcommission: u64The rate at which protocol charges commission.community_rebate: u64When 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: u128Total balance of all the tickets issues since genesis.total_claims_balance_cleared: u128Total balance of all the redeemed tickets since genesis.cooldown_period: u64time 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: u64timestamp of the contract deployment.commission_exempt_amount: u64principal over which protocol's commission is calculated.min_transaction_amount: u64minimum 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.

Resource EventStore

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
Fields
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 delegator

Resource StakedAptosCapabilities

Capabilities associated with tAPT coin
struct StakedAptosCapabilities has key

Struct Ticket

The ticket which is issued when a user unstakes.
struct Ticket has store
Fields
id: u128unique id, used to ensure first-in-first-out for claimsamount: u64amount in APT that will be redeemedtimestamp: u64timestamp when the ticket was issued

Resource TicketStore

A user can have several tickets which are stored in their ticket store
struct TicketStore has key
Fields
tickets: vector<stake_router::Ticket>List of all unclaimed tickets the user has.

Struct StakeEvent

Event emitted when a user stakes
struct StakeEvent has drop, store
Fields
delegator: addressaccount addressamount: u64amount staked in APTt_apt_coins: u64tAPT coins receivedtimestamp: u64timestamp when the event happened

Struct UnstakeEvent

Event emitted when a user unstakes
struct UnstakeEvent has drop, store
Fields
delegator: addressaccount addressamount: u64amount unstaked in APTt_apt_coins: u64tAPT coins burnedtimestamp: u64timestamp when the event happened

Struct ClaimEvent

Event emitted when a user claims a ticket
struct ClaimEvent has drop, store
Fields
delegator: addressAccount addressamount: u64Amount claimed in APTticket_index: u64Index of the ticket in the TicketStoretimestamp: u64timestamp when the event happened

Struct IncreaseStakeInValidatorEvent

Event emitted when a user increases stake in a validator
struct IncreaseStakeInValidatorEvent has drop, store
Fields
managed_pool_address: addressAddress of the validator's ManagedStakingPoolamount: u64The increase in stake in APTtimestamp: u64timestamp when the event happened

Struct UnlockFromValidatorEvent

Event emitted when a user unlocks funds from a validator
struct UnlockFromValidatorEvent has drop, store
Fields
managed_pool_address: addressAddress of the validator's ManagedStakingPoolamount: u64The amount of funds unlocked in APTtimestamp: u64timestamp when the event happened

Constants

When 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;

Function get_t_apt_supply

Returns the total tAPT minted.
public fun get_t_apt_supply(): u64
Implementation
public fun get_t_apt_supply(): u64 {
(option::extract(&mut coin::supply<StakedAptosCoin>()) as u64)
}

Function get_total_value

Returns the total value locked with the protocol in APT (legacy name).
public fun get_total_value(): u64
Implementation
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)
}

Function current_total_stakable_amount

Returns how much APT can be staked with validators.
public fun current_total_stakable_amount(): u64
Implementation
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
}
}

Function current_deficit

This function calculates how much needs to be unlocked from the validators, to be able to process unclaimed funds.
public fun current_deficit(): u64
Implementation
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)
}
}

Function get_num_tickets

Returns the total number of tickets that delegator has.
public fun get_num_tickets(delegator: address): u64
Implementation
public fun get_num_tickets(delegator: address): u64 acquires TicketStore {
if (!exists<TicketStore>(delegator)) {
0
} else {
let ticket_store = borrow_global<TicketStore>(delegator);
vector::length(&ticket_store.tickets)
}
}

Function get_ticket

Returns the ticket at position index for delegator as a 3-tuple of:
  • id: u128: unique id, used to ensure first-in-first-out for claims,
  • amount: u64, amount in APT that will be redeemed, and,
  • timestamp: u64: timestamp when the ticket was issued.

Abort condition

  • If delegator does not have a ticket at index.
public fun get_ticket(delegator: address, index: u64): (u128, u64, u64)
Implementation
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)
}

Function initialize_tortuga_liquid_staking

This initializes the liquid staking protocol for tortuga with the given commission, cooldown_period and max_number_of_validators.

Restrictions

Abort conditions

public fun initialize_tortuga_liquid_staking(tortuga_governance_deployer: &signer, commission: u64, cooldown_period: u64, max_number_of_validators: u64)
Implementation
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);
}

Function set_commission

Set the commission that tortuga protocol charges to value.

Restrictions

Abort conditions

public fun set_commission(tortuga_governance: &signer, value: u64)
Implementation
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;
}

Function set_community_rebate

Set the community rebate that tortuga users get to value.

Restrictions

Abort conditions

  • If the value is greater than MAX_WITHDRAWAL_FEE, i.e. 0.05%.
public fun set_community_rebate(tortuga_governance: &signer, value: u64)
Implementation
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;
}

Function set_cooldown_period

Set wait period between ticket issuance and redemption.

Restrictions

Abort conditions

public fun set_cooldown_period(tortuga_governance: &signer, value: u64)
Implementation
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;
}

Function set_min_transaction_amount

Set minimum delegation amount.

Restrictions

public fun set_min_transaction_amount(tortuga_governance: &signer, value: u64)
Implementation
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;
}

Function stake

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.

Abort conditions

  • If the amount is less than min_transaction_amount.
public fun stake(delegator: &signer, amount: u64)
Implementation
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);
}

Function unstake

Request to redeem APT by burn t_apt_amount tAPT.

Abort conditions

  • If the t_apt_amount is less than min_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)
Implementation
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
}
);
}

Function claim

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.
Note: The tickets in TicketStore are not guaranteed to be sorted by their timestamp field.

Abort conditions

  • If the delegator does not have a TicketStore.
  • 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)
Implementation
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);
}

Function increase_stake_in_validator

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.

Abort conditions

public fun increase_stake_in_validator(managed_pool_address: address, amount: u64)
Implementation
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()
}
);
}

Function unlock_from_validator

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.

Abort conditions