Lockup realization trait
This commit is contained in:
parent
1f31770c83
commit
a6cc210595
|
@ -74,6 +74,7 @@ pub mod lockup {
|
|||
period_count: u64,
|
||||
deposit_amount: u64,
|
||||
nonce: u8,
|
||||
realizor: Option<Realizor>,
|
||||
) -> Result<()> {
|
||||
if end_ts <= ctx.accounts.clock.unix_timestamp {
|
||||
return Err(ErrorCode::InvalidTimestamp.into());
|
||||
|
@ -100,12 +101,14 @@ pub mod lockup {
|
|||
vesting.whitelist_owned = 0;
|
||||
vesting.grantor = *ctx.accounts.depositor_authority.key;
|
||||
vesting.nonce = nonce;
|
||||
vesting.realizor = realizor;
|
||||
|
||||
token::transfer(ctx.accounts.into(), deposit_amount)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[access_control(is_realized(&ctx))]
|
||||
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
|
||||
// Has the given amount vested?
|
||||
if amount
|
||||
|
@ -187,7 +190,7 @@ pub mod lockup {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// Convenience function for UI's to calculate the withdrawalable amount.
|
||||
// Convenience function for UI's to calculate the withdrawable amount.
|
||||
pub fn available_for_withdrawal(ctx: Context<AvailableForWithdrawal>) -> Result<()> {
|
||||
let available = calculator::available_for_withdrawal(
|
||||
&ctx.accounts.vesting,
|
||||
|
@ -242,6 +245,8 @@ impl<'info> CreateVesting<'info> {
|
|||
}
|
||||
}
|
||||
|
||||
// All accounts not included here, i.e., the "remaining accounts" should be
|
||||
// ordered according to the realization interface.
|
||||
#[derive(Accounts)]
|
||||
pub struct Withdraw<'info> {
|
||||
// Vesting.
|
||||
|
@ -327,6 +332,29 @@ pub struct Vesting {
|
|||
pub whitelist_owned: u64,
|
||||
/// Signer nonce.
|
||||
pub nonce: u8,
|
||||
/// The program that determines when the locked account is **realized**.
|
||||
/// In addition to the lockup schedule, the program provides the ability
|
||||
/// for applications to determine when locked tokens are considered earned.
|
||||
/// For example, when earning locked tokens via the staking program, one
|
||||
/// cannot receive the tokens until unstaking. As a result, if one never
|
||||
/// unstakes, one would never actually receive the locked tokens.
|
||||
pub realizor: Option<Realizor>,
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||
pub struct Realizor {
|
||||
/// Program to invoke to check a realization condition. This program must
|
||||
/// implement the `RealizeLock` trait.
|
||||
pub program: Pubkey,
|
||||
/// Address of an arbitrary piece of metadata interpretable by the realizor
|
||||
/// program. For example, when a vesting account is allocated, the program
|
||||
/// can define its realization condition as a function of some account
|
||||
/// state. The metadata is the address of that account.
|
||||
///
|
||||
/// In the case of staking, the metadata is a `Member` account address. When
|
||||
/// the realization condition is checked, the staking program will check the
|
||||
/// `Member` account defined by the `metadata` has no staked tokens.
|
||||
pub metadata: Pubkey,
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, PartialEq, Default, Copy, Clone)]
|
||||
|
@ -366,6 +394,12 @@ pub enum ErrorCode {
|
|||
WhitelistEntryNotFound,
|
||||
#[msg("You do not have sufficient permissions to perform this action.")]
|
||||
Unauthorized,
|
||||
#[msg("You are unable to realize projected rewards until unstaking.")]
|
||||
UnableToWithdrawWhileStaked,
|
||||
#[msg("The given lock realizor doesn't match the vesting account.")]
|
||||
InvalidLockRealizor,
|
||||
#[msg("You have not realized this vesting account.")]
|
||||
UnrealizedVesting,
|
||||
}
|
||||
|
||||
impl<'a, 'b, 'c, 'info> From<&mut CreateVesting<'info>>
|
||||
|
@ -456,3 +490,34 @@ fn whitelist_auth(lockup: &Lockup, ctx: &Context<Auth>) -> Result<()> {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Returns Ok if the locked vesting account has been "realized". Realization
|
||||
// is application dependent. For example, in the case of staking, one must first
|
||||
// unstake before being able to earn locked tokens.
|
||||
fn is_realized<'info>(ctx: &Context<Withdraw>) -> Result<()> {
|
||||
if let Some(realizor) = &ctx.accounts.vesting.realizor {
|
||||
let cpi_program = {
|
||||
let p = ctx.remaining_accounts[0].clone();
|
||||
if p.key != &realizor.program {
|
||||
return Err(ErrorCode::InvalidLockRealizor.into());
|
||||
}
|
||||
p
|
||||
};
|
||||
let cpi_accounts = ctx.remaining_accounts.to_vec()[1..].to_vec();
|
||||
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
|
||||
let vesting = (*ctx.accounts.vesting).clone();
|
||||
realize_lock::is_realized(cpi_ctx, vesting).map_err(|_| ErrorCode::UnrealizedVesting)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RealizeLock defines the interface an external program must implement if
|
||||
/// they want to define a "realization condition" on a locked vesting account.
|
||||
/// This condition must be satisfied *even if a vesting schedule has
|
||||
/// completed*. Otherwise the user can never earn the locked funds. For example,
|
||||
/// in the case of the staking program, one cannot received a locked reward
|
||||
/// until one has completely unstaked.
|
||||
#[interface]
|
||||
pub trait RealizeLock<'info, T: Accounts<'info>> {
|
||||
fn is_realized(ctx: Context<T>, v: Vesting) -> ProgramResult;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
use anchor_lang::prelude::*;
|
||||
use anchor_lang::solana_program::program_option::COption;
|
||||
use anchor_spl::token::{self, Mint, TokenAccount, Transfer};
|
||||
use lockup::{CreateVesting, Vesting};
|
||||
use lockup::{CreateVesting, RealizeLock, Realizor, Vesting};
|
||||
use std::convert::Into;
|
||||
|
||||
#[program]
|
||||
|
@ -26,6 +26,23 @@ mod registry {
|
|||
}
|
||||
}
|
||||
|
||||
impl<'info> RealizeLock<'info, IsRealized<'info>> for Registry {
|
||||
fn is_realized(ctx: Context<IsRealized>, v: Vesting) -> ProgramResult {
|
||||
if let Some(realizor) = &v.realizor {
|
||||
if &realizor.metadata != ctx.accounts.member.to_account_info().key {
|
||||
return Err(ErrorCode::InvalidRealizorMetadata.into());
|
||||
}
|
||||
assert!(ctx.accounts.member.beneficiary == v.beneficiary);
|
||||
let total_staked =
|
||||
ctx.accounts.member_spt.amount + ctx.accounts.member_spt_locked.amount;
|
||||
if total_staked != 0 {
|
||||
return Err(ErrorCode::UnrealizedReward.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[access_control(Initialize::accounts(&ctx, nonce))]
|
||||
pub fn initialize(
|
||||
ctx: Context<Initialize>,
|
||||
|
@ -435,14 +452,27 @@ mod registry {
|
|||
.unwrap();
|
||||
assert!(reward_amount > 0);
|
||||
|
||||
// Lockup program requires the timestamp to be >= clock's timestamp.
|
||||
// So update if the time has already passed. 60 seconds is arbitrary.
|
||||
let end_ts = match end_ts > ctx.accounts.cmn.clock.unix_timestamp + 60 {
|
||||
true => end_ts,
|
||||
false => ctx.accounts.cmn.clock.unix_timestamp + 60,
|
||||
// The lockup program requires the timestamp to be >= clock's timestamp.
|
||||
// So update if the time has already passed.
|
||||
//
|
||||
// If the reward is within `period_count` seconds of fully vesting, then
|
||||
// we bump the `end_ts` because, otherwise, the vesting account would
|
||||
// fail to be created. Vesting must have no more frequently than the
|
||||
// smallest unit of time, once per second, expressed as
|
||||
// `period_count <= end_ts - start_ts`.
|
||||
let end_ts = match end_ts < ctx.accounts.cmn.clock.unix_timestamp + period_count as i64 {
|
||||
true => ctx.accounts.cmn.clock.unix_timestamp + period_count as i64,
|
||||
false => end_ts,
|
||||
};
|
||||
|
||||
// Create lockup account for the member's beneficiary.
|
||||
// Specify the vesting account's realizor, so that unlocks can only
|
||||
// execute once completely unstaked.
|
||||
let realizor = Some(Realizor {
|
||||
program: *ctx.program_id,
|
||||
metadata: *ctx.accounts.cmn.member.to_account_info().key,
|
||||
});
|
||||
|
||||
// CPI: Create lockup account for the member's beneficiary.
|
||||
let seeds = &[
|
||||
ctx.accounts.cmn.registrar.to_account_info().key.as_ref(),
|
||||
ctx.accounts.cmn.vendor.to_account_info().key.as_ref(),
|
||||
|
@ -461,9 +491,10 @@ mod registry {
|
|||
period_count,
|
||||
reward_amount,
|
||||
nonce,
|
||||
realizor,
|
||||
)?;
|
||||
|
||||
// Update the member account.
|
||||
// Make sure this reward can't be processed more than once.
|
||||
let member = &mut ctx.accounts.cmn.member;
|
||||
member.rewards_cursor = ctx.accounts.cmn.vendor.reward_event_q_cursor + 1;
|
||||
|
||||
|
@ -609,6 +640,17 @@ pub struct Ctor<'info> {
|
|||
lockup_program: AccountInfo<'info>,
|
||||
}
|
||||
|
||||
#[derive(Accounts)]
|
||||
pub struct IsRealized<'info> {
|
||||
#[account(
|
||||
"&member.balances.spt == member_spt.to_account_info().key",
|
||||
"&member.balances_locked.spt == member_spt_locked.to_account_info().key"
|
||||
)]
|
||||
member: ProgramAccount<'info, Member>,
|
||||
member_spt: CpiAccount<'info, TokenAccount>,
|
||||
member_spt_locked: CpiAccount<'info, TokenAccount>,
|
||||
}
|
||||
|
||||
#[derive(Accounts)]
|
||||
pub struct UpdateMember<'info> {
|
||||
#[account(mut, has_one = beneficiary)]
|
||||
|
@ -1168,6 +1210,12 @@ pub enum ErrorCode {
|
|||
ExpectedUnlockedVendor,
|
||||
#[msg("Locked deposit from an invalid deposit authority.")]
|
||||
InvalidVestingSigner,
|
||||
#[msg("Locked rewards cannot be realized until one unstaked all tokens.")]
|
||||
UnrealizedReward,
|
||||
#[msg("The beneficiary doesn't match.")]
|
||||
InvalidBeneficiary,
|
||||
#[msg("The given member account does not match the realizor metadata.")]
|
||||
InvalidRealizorMetadata,
|
||||
}
|
||||
|
||||
impl<'a, 'b, 'c, 'info> From<&mut Deposit<'info>>
|
||||
|
|
|
@ -159,6 +159,7 @@ describe("Lockup and Registry", () => {
|
|||
periodCount,
|
||||
depositAmount,
|
||||
nonce,
|
||||
null, // Lock realizor is None.
|
||||
{
|
||||
accounts: {
|
||||
vesting: vesting.publicKey,
|
||||
|
@ -194,6 +195,7 @@ describe("Lockup and Registry", () => {
|
|||
assert.ok(vestingAccount.whitelistOwned.eq(new anchor.BN(0)));
|
||||
assert.equal(vestingAccount.nonce, nonce);
|
||||
assert.ok(endTs.gt(vestingAccount.startTs));
|
||||
assert.ok(vestingAccount.realizor === null);
|
||||
});
|
||||
|
||||
it("Fails to withdraw from a vesting account before vesting", async () => {
|
||||
|
@ -580,8 +582,8 @@ describe("Lockup and Registry", () => {
|
|||
it("Drops a locked reward", async () => {
|
||||
lockedRewardKind = {
|
||||
locked: {
|
||||
endTs: new anchor.BN(Date.now() / 1000 + 70),
|
||||
periodCount: new anchor.BN(10),
|
||||
endTs: new anchor.BN(Date.now() / 1000 + 5),
|
||||
periodCount: new anchor.BN(3),
|
||||
},
|
||||
};
|
||||
lockedRewardAmount = new anchor.BN(200);
|
||||
|
@ -658,16 +660,21 @@ describe("Lockup and Registry", () => {
|
|||
assert.ok(e.locked === true);
|
||||
});
|
||||
|
||||
it("Collects a locked reward", async () => {
|
||||
const vendoredVesting = new anchor.web3.Account();
|
||||
const vendoredVestingVault = new anchor.web3.Account();
|
||||
let vendoredVesting = null;
|
||||
let vendoredVestingVault = null;
|
||||
let vendoredVestingSigner = null;
|
||||
|
||||
it("Claims a locked reward", async () => {
|
||||
vendoredVesting = new anchor.web3.Account();
|
||||
vendoredVestingVault = new anchor.web3.Account();
|
||||
let [
|
||||
vendoredVestingSigner,
|
||||
_vendoredVestingSigner,
|
||||
nonce,
|
||||
] = await anchor.web3.PublicKey.findProgramAddress(
|
||||
[vendoredVesting.publicKey.toBuffer()],
|
||||
lockup.programId
|
||||
);
|
||||
vendoredVestingSigner = _vendoredVestingSigner;
|
||||
const remainingAccounts = lockup.instruction.createVesting
|
||||
.accounts({
|
||||
vesting: vendoredVesting.publicKey,
|
||||
|
@ -731,6 +738,51 @@ describe("Lockup and Registry", () => {
|
|||
lockupAccount.periodCount.eq(lockedRewardKind.locked.periodCount)
|
||||
);
|
||||
assert.ok(lockupAccount.whitelistOwned.eq(new anchor.BN(0)));
|
||||
assert.ok(lockupAccount.realizor.program.equals(registry.programId));
|
||||
assert.ok(lockupAccount.realizor.metadata.equals(member.publicKey));
|
||||
});
|
||||
|
||||
it("Waits for the lockup period to pass", async () => {
|
||||
await serumCmn.sleep(10 * 1000);
|
||||
});
|
||||
|
||||
it("Should fail to unlock an unrealized lockup reward", async () => {
|
||||
const token = await serumCmn.createTokenAccount(
|
||||
provider,
|
||||
mint,
|
||||
provider.wallet.publicKey
|
||||
);
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
const withdrawAmount = new anchor.BN(10);
|
||||
await lockup.rpc.withdraw(withdrawAmount, {
|
||||
accounts: {
|
||||
vesting: vendoredVesting.publicKey,
|
||||
beneficiary: provider.wallet.publicKey,
|
||||
token,
|
||||
vault: vendoredVestingVault.publicKey,
|
||||
vestingSigner: vendoredVestingSigner,
|
||||
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||
},
|
||||
// TODO: trait methods generated on the client. Until then, we need to manually
|
||||
// specify the account metas here.
|
||||
remainingAccounts: [
|
||||
{ pubkey: registry.programId, isWritable: false, isSigner: false },
|
||||
{ pubkey: member.publicKey, isWritable: false, isSigner: false },
|
||||
{ pubkey: balances.spt, isWritable: false, isSigner: false },
|
||||
{ pubkey: balancesLocked.spt, isWritable: false, isSigner: false },
|
||||
],
|
||||
});
|
||||
},
|
||||
(err) => {
|
||||
// Solana doesn't propagate errors across CPI. So we receive the registry's error code,
|
||||
// not the lockup's.
|
||||
const errorCode = "custom program error: 0x78";
|
||||
assert.ok(err.toString().split(errorCode).length === 2);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const pendingWithdrawal = new anchor.web3.Account();
|
||||
|
@ -857,4 +909,35 @@ describe("Lockup and Registry", () => {
|
|||
const tokenAccount = await serumCmn.getTokenAccount(provider, token);
|
||||
assert.ok(tokenAccount.amount.eq(withdrawAmount));
|
||||
});
|
||||
|
||||
it("Should succesfully unlock a locked reward after unstaking", async () => {
|
||||
const token = await serumCmn.createTokenAccount(
|
||||
provider,
|
||||
mint,
|
||||
provider.wallet.publicKey
|
||||
);
|
||||
|
||||
const withdrawAmount = new anchor.BN(7);
|
||||
await lockup.rpc.withdraw(withdrawAmount, {
|
||||
accounts: {
|
||||
vesting: vendoredVesting.publicKey,
|
||||
beneficiary: provider.wallet.publicKey,
|
||||
token,
|
||||
vault: vendoredVestingVault.publicKey,
|
||||
vestingSigner: vendoredVestingSigner,
|
||||
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||
},
|
||||
// TODO: trait methods generated on the client. Until then, we need to manually
|
||||
// specify the account metas here.
|
||||
remainingAccounts: [
|
||||
{ pubkey: registry.programId, isWritable: false, isSigner: false },
|
||||
{ pubkey: member.publicKey, isWritable: false, isSigner: false },
|
||||
{ pubkey: balances.spt, isWritable: false, isSigner: false },
|
||||
{ pubkey: balancesLocked.spt, isWritable: false, isSigner: false },
|
||||
],
|
||||
});
|
||||
const tokenAccount = await serumCmn.getTokenAccount(provider, token);
|
||||
assert.ok(tokenAccount.amount.eq(withdrawAmount));
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue