Lockup realization trait

This commit is contained in:
Armani Ferrante 2021-02-07 23:45:26 +08:00
parent 1f31770c83
commit a6cc210595
No known key found for this signature in database
GPG Key ID: D597A80BCF8E12B7
3 changed files with 211 additions and 15 deletions

View File

@ -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;
}

View File

@ -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>>

View File

@ -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));
});
});