Browse Source

Lockup realization trait

Armani Ferrante 4 years ago
parent
commit
a6cc210595

+ 66 - 1
examples/lockup/programs/lockup/src/lib.rs

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

+ 56 - 8
examples/lockup/programs/registry/src/lib.rs

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

+ 89 - 6
examples/lockup/tests/lockup.js

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