Skip to content

Commit 3311787

Browse files
authored
solana: Add SPL multisig support (#568)
* `initialize_multisig` and `release_inbound_multisig_mint` to act as multisig variants of initialize and release_inbound_mint respectively * Add SPLMultisig `InterfaceAccount` wrapper * Refactor out common structs and function logic to avoid duplication * Update test to verify independent minting capability after transferring authority to multisig * Update IDL
1 parent dcc0305 commit 3311787

File tree

10 files changed

+979
-89
lines changed

10 files changed

+979
-89
lines changed

solana/programs/example-native-token-transfers/src/error.rs

+2
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ pub enum NTTError {
5959
InvalidPendingTokenAuthority,
6060
#[msg("IncorrectRentPayer")]
6161
IncorrectRentPayer,
62+
#[msg("InvalidMultisig")]
63+
InvalidMultisig,
6264
}
6365

6466
impl From<ScalingError> for NTTError {

solana/programs/example-native-token-transfers/src/instructions/initialize.rs

+67-18
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ use crate::{
1010
bitmap::Bitmap,
1111
error::NTTError,
1212
queue::{outbox::OutboxRateLimit, rate_limit::RateLimitState},
13+
spl_multisig::SplMultisig,
1314
};
1415

1516
#[derive(Accounts)]
16-
#[instruction(args: InitializeArgs)]
1717
pub struct Initialize<'info> {
1818
#[account(mut)]
1919
pub payer: Signer<'info>,
@@ -37,12 +37,8 @@ pub struct Initialize<'info> {
3737
)]
3838
pub config: Box<Account<'info, crate::config::Config>>,
3939

40-
#[account(
41-
constraint =
42-
args.mode == Mode::Locking
43-
|| mint.mint_authority.unwrap() == token_authority.key()
44-
@ NTTError::InvalidMintAuthority,
45-
)]
40+
// NOTE: this account is unconstrained and is the responsibility of the
41+
// handler to constrain it
4642
pub mint: Box<InterfaceAccount<'info, token_interface::Mint>>,
4743

4844
#[account(
@@ -76,7 +72,7 @@ pub struct Initialize<'info> {
7672
/// The custody account that holds tokens in locking mode and temporarily
7773
/// holds tokens in burning mode.
7874
/// CHECK: Use init_if_needed here to prevent a denial-of-service of the [`initialize`]
79-
/// function if the token account has already been created.
75+
/// function if the token account has already been created.
8076
pub custody: InterfaceAccount<'info, token_interface::TokenAccount>,
8177

8278
/// CHECK: checked to be the appropriate token program when initialising the
@@ -96,24 +92,77 @@ pub struct InitializeArgs {
9692
}
9793

9894
pub fn initialize(ctx: Context<Initialize>, args: InitializeArgs) -> Result<()> {
99-
ctx.accounts.config.set_inner(crate::config::Config {
100-
bump: ctx.bumps.config,
101-
mint: ctx.accounts.mint.key(),
102-
token_program: ctx.accounts.token_program.key(),
103-
mode: args.mode,
104-
chain_id: ChainId { id: args.chain_id },
105-
owner: ctx.accounts.deployer.key(),
95+
// NOTE: this check was moved into the function body to reuse the `Initialize` struct
96+
// in the multisig variant while preserving ABI
97+
if args.mode == Mode::Burning
98+
&& ctx.accounts.mint.mint_authority.unwrap() != ctx.accounts.token_authority.key()
99+
{
100+
return Err(NTTError::InvalidMintAuthority.into());
101+
}
102+
103+
initialize_config_and_rate_limit(
104+
ctx.accounts,
105+
ctx.bumps.config,
106+
args.chain_id,
107+
args.limit,
108+
args.mode,
109+
)
110+
}
111+
112+
#[derive(Accounts)]
113+
#[instruction(args: InitializeArgs)]
114+
pub struct InitializeMultisig<'info> {
115+
#[account(
116+
constraint =
117+
args.mode == Mode::Locking
118+
|| common.mint.mint_authority.unwrap() == multisig.key()
119+
@ NTTError::InvalidMintAuthority,
120+
)]
121+
pub common: Initialize<'info>,
122+
123+
#[account(
124+
constraint =
125+
multisig.m == 1 && multisig.signers.contains(&common.token_authority.key())
126+
@ NTTError::InvalidMultisig,
127+
)]
128+
pub multisig: InterfaceAccount<'info, SplMultisig>,
129+
}
130+
131+
pub fn initialize_multisig(ctx: Context<InitializeMultisig>, args: InitializeArgs) -> Result<()> {
132+
initialize_config_and_rate_limit(
133+
&mut ctx.accounts.common,
134+
ctx.bumps.common.config,
135+
args.chain_id,
136+
args.limit,
137+
args.mode,
138+
)
139+
}
140+
141+
fn initialize_config_and_rate_limit(
142+
common: &mut Initialize<'_>,
143+
config_bump: u8,
144+
chain_id: u16,
145+
limit: u64,
146+
mode: ntt_messages::mode::Mode,
147+
) -> Result<()> {
148+
common.config.set_inner(crate::config::Config {
149+
bump: config_bump,
150+
mint: common.mint.key(),
151+
token_program: common.token_program.key(),
152+
mode,
153+
chain_id: ChainId { id: chain_id },
154+
owner: common.deployer.key(),
106155
pending_owner: None,
107156
paused: false,
108157
next_transceiver_id: 0,
109158
// NOTE: can't be changed for now
110159
threshold: 1,
111160
enabled_transceivers: Bitmap::new(),
112-
custody: ctx.accounts.custody.key(),
161+
custody: common.custody.key(),
113162
});
114163

115-
ctx.accounts.rate_limit.set_inner(OutboxRateLimit {
116-
rate_limit: RateLimitState::new(args.limit),
164+
common.rate_limit.set_inner(OutboxRateLimit {
165+
rate_limit: RateLimitState::new(limit),
117166
});
118167

119168
Ok(())

solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs

+109-29
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::{
77
config::*,
88
error::NTTError,
99
queue::inbox::{InboxItem, ReleaseStatus},
10+
spl_multisig::SplMultisig,
1011
};
1112

1213
#[derive(Accounts)]
@@ -77,18 +78,11 @@ pub fn release_inbound_mint<'info>(
7778
ctx: Context<'_, '_, '_, 'info, ReleaseInboundMint<'info>>,
7879
args: ReleaseInboundArgs,
7980
) -> Result<()> {
80-
let inbox_item = &mut ctx.accounts.common.inbox_item;
81-
82-
let released = inbox_item.try_release()?;
83-
84-
if !released {
85-
if args.revert_on_delay {
86-
return Err(NTTError::CantReleaseYet.into());
87-
} else {
88-
return Ok(());
89-
}
81+
let inbox_item = release_inbox_item(&mut ctx.accounts.common.inbox_item, args.revert_on_delay)?;
82+
if inbox_item.is_none() {
83+
return Ok(());
9084
}
91-
85+
let inbox_item = inbox_item.unwrap();
9286
assert!(inbox_item.release_status == ReleaseStatus::Released);
9387

9488
// NOTE: minting tokens is a two-step process:
@@ -106,6 +100,11 @@ pub fn release_inbound_mint<'info>(
106100
// The [`transfer_burn`] function operates in a similar way
107101
// (transfer to custody from sender, *then* burn).
108102

103+
let token_authority_sig: &[&[&[u8]]] = &[&[
104+
crate::TOKEN_AUTHORITY_SEED,
105+
&[ctx.bumps.common.token_authority],
106+
]];
107+
109108
// Step 1: mint tokens to the custody account
110109
token_interface::mint_to(
111110
CpiContext::new_with_signer(
@@ -115,10 +114,7 @@ pub fn release_inbound_mint<'info>(
115114
to: ctx.accounts.common.custody.to_account_info(),
116115
authority: ctx.accounts.common.token_authority.to_account_info(),
117116
},
118-
&[&[
119-
crate::TOKEN_AUTHORITY_SEED,
120-
&[ctx.bumps.common.token_authority],
121-
]],
117+
token_authority_sig,
122118
),
123119
inbox_item.amount,
124120
)?;
@@ -133,10 +129,87 @@ pub fn release_inbound_mint<'info>(
133129
ctx.remaining_accounts,
134130
inbox_item.amount,
135131
ctx.accounts.common.mint.decimals,
136-
&[&[
137-
crate::TOKEN_AUTHORITY_SEED,
138-
&[ctx.bumps.common.token_authority],
139-
]],
132+
token_authority_sig,
133+
)?;
134+
Ok(())
135+
}
136+
137+
#[derive(Accounts)]
138+
pub struct ReleaseInboundMintMultisig<'info> {
139+
#[account(
140+
constraint = common.config.mode == Mode::Burning @ NTTError::InvalidMode,
141+
)]
142+
common: ReleaseInbound<'info>,
143+
144+
#[account(
145+
constraint =
146+
multisig.m == 1 && multisig.signers.contains(&common.token_authority.key())
147+
@ NTTError::InvalidMultisig,
148+
)]
149+
pub multisig: InterfaceAccount<'info, SplMultisig>,
150+
}
151+
152+
pub fn release_inbound_mint_multisig<'info>(
153+
ctx: Context<'_, '_, '_, 'info, ReleaseInboundMintMultisig<'info>>,
154+
args: ReleaseInboundArgs,
155+
) -> Result<()> {
156+
let inbox_item = release_inbox_item(&mut ctx.accounts.common.inbox_item, args.revert_on_delay)?;
157+
if inbox_item.is_none() {
158+
return Ok(());
159+
}
160+
let inbox_item = inbox_item.unwrap();
161+
assert!(inbox_item.release_status == ReleaseStatus::Released);
162+
163+
// NOTE: minting tokens is a two-step process:
164+
// 1. Mint tokens to the custody account
165+
// 2. Transfer the tokens from the custody account to the recipient
166+
//
167+
// This is done to ensure that if the token has a transfer hook defined, it
168+
// will be called after the tokens are minted.
169+
// Unfortunately the Token2022 program doesn't trigger transfer hooks when
170+
// minting tokens, so we have to do it "manually" via a transfer.
171+
//
172+
// If we didn't do this, transfer hooks could be bypassed by transferring
173+
// the tokens out through NTT first, then back in to the intended recipient.
174+
//
175+
// The [`transfer_burn`] function operates in a similar way
176+
// (transfer to custody from sender, *then* burn).
177+
178+
let token_authority_sig: &[&[&[u8]]] = &[&[
179+
crate::TOKEN_AUTHORITY_SEED,
180+
&[ctx.bumps.common.token_authority],
181+
]];
182+
183+
// Step 1: mint tokens to the custody account
184+
solana_program::program::invoke_signed(
185+
&spl_token_2022::instruction::mint_to(
186+
&ctx.accounts.common.token_program.key(),
187+
&ctx.accounts.common.mint.key(),
188+
&ctx.accounts.common.custody.key(),
189+
&ctx.accounts.multisig.key(),
190+
&[&ctx.accounts.common.token_authority.key()],
191+
inbox_item.amount,
192+
)?,
193+
&[
194+
ctx.accounts.common.custody.to_account_info(),
195+
ctx.accounts.common.mint.to_account_info(),
196+
ctx.accounts.common.token_authority.to_account_info(),
197+
ctx.accounts.multisig.to_account_info(),
198+
],
199+
token_authority_sig,
200+
)?;
201+
202+
// Step 2: transfer the tokens from the custody account to the recipient
203+
onchain::invoke_transfer_checked(
204+
&ctx.accounts.common.token_program.key(),
205+
ctx.accounts.common.custody.to_account_info(),
206+
ctx.accounts.common.mint.to_account_info(),
207+
ctx.accounts.common.recipient.to_account_info(),
208+
ctx.accounts.common.token_authority.to_account_info(),
209+
ctx.remaining_accounts,
210+
inbox_item.amount,
211+
ctx.accounts.common.mint.decimals,
212+
token_authority_sig,
140213
)?;
141214
Ok(())
142215
}
@@ -162,17 +235,12 @@ pub fn release_inbound_unlock<'info>(
162235
ctx: Context<'_, '_, '_, 'info, ReleaseInboundUnlock<'info>>,
163236
args: ReleaseInboundArgs,
164237
) -> Result<()> {
165-
let inbox_item = &mut ctx.accounts.common.inbox_item;
166-
167-
let released = inbox_item.try_release()?;
168-
169-
if !released {
170-
if args.revert_on_delay {
171-
return Err(NTTError::CantReleaseYet.into());
172-
} else {
173-
return Ok(());
174-
}
238+
let inbox_item = release_inbox_item(&mut ctx.accounts.common.inbox_item, args.revert_on_delay)?;
239+
if inbox_item.is_none() {
240+
return Ok(());
175241
}
242+
let inbox_item = inbox_item.unwrap();
243+
assert!(inbox_item.release_status == ReleaseStatus::Released);
176244

177245
onchain::invoke_transfer_checked(
178246
&ctx.accounts.common.token_program.key(),
@@ -190,3 +258,15 @@ pub fn release_inbound_unlock<'info>(
190258
)?;
191259
Ok(())
192260
}
261+
fn release_inbox_item(
262+
inbox_item: &mut InboxItem,
263+
revert_on_delay: bool,
264+
) -> Result<Option<&mut InboxItem>> {
265+
if inbox_item.try_release()? {
266+
Ok(Some(inbox_item))
267+
} else if revert_on_delay {
268+
Err(NTTError::CantReleaseYet.into())
269+
} else {
270+
Ok(None)
271+
}
272+
}

solana/programs/example-native-token-transfers/src/lib.rs

+15
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub mod peer;
2121
pub mod pending_token_authority;
2222
pub mod queue;
2323
pub mod registered_transceiver;
24+
pub mod spl_multisig;
2425
pub mod transceivers;
2526
pub mod transfer;
2627

@@ -75,6 +76,13 @@ pub mod example_native_token_transfers {
7576
instructions::initialize(ctx, args)
7677
}
7778

79+
pub fn initialize_multisig(
80+
ctx: Context<InitializeMultisig>,
81+
args: InitializeArgs,
82+
) -> Result<()> {
83+
instructions::initialize_multisig(ctx, args)
84+
}
85+
7886
pub fn initialize_lut(ctx: Context<InitializeLUT>, recent_slot: u64) -> Result<()> {
7987
instructions::initialize_lut(ctx, recent_slot)
8088
}
@@ -108,6 +116,13 @@ pub mod example_native_token_transfers {
108116
instructions::release_inbound_mint(ctx, args)
109117
}
110118

119+
pub fn release_inbound_mint_multisig<'info>(
120+
ctx: Context<'_, '_, '_, 'info, ReleaseInboundMintMultisig<'info>>,
121+
args: ReleaseInboundArgs,
122+
) -> Result<()> {
123+
instructions::release_inbound_mint_multisig(ctx, args)
124+
}
125+
111126
pub fn release_inbound_unlock<'info>(
112127
ctx: Context<'_, '_, '_, 'info, ReleaseInboundUnlock<'info>>,
113128
args: ReleaseInboundArgs,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
use anchor_lang::{prelude::*, solana_program::program_pack::Pack, Ids, Owners};
2+
use anchor_spl::token_interface::TokenInterface;
3+
use std::ops::Deref;
4+
5+
/// Anchor does not have a SPL Multisig wrapper as a part of the token interface:
6+
/// https://docs.rs/anchor-spl/0.29.0/src/anchor_spl/token_interface.rs.html
7+
/// Thus, we have to write our own wrapper to use with `InterfaceAccount`
8+
9+
#[derive(Clone, Debug, Default, PartialEq)]
10+
pub struct SplMultisig(spl_token_2022::state::Multisig);
11+
12+
impl AccountDeserialize for SplMultisig {
13+
fn try_deserialize_unchecked(buf: &mut &[u8]) -> anchor_lang::Result<Self> {
14+
Ok(SplMultisig(spl_token_2022::state::Multisig::unpack(buf)?))
15+
}
16+
}
17+
18+
impl AccountSerialize for SplMultisig {}
19+
20+
impl Owners for SplMultisig {
21+
fn owners() -> &'static [Pubkey] {
22+
TokenInterface::ids()
23+
}
24+
}
25+
26+
impl Deref for SplMultisig {
27+
type Target = spl_token_2022::state::Multisig;
28+
29+
fn deref(&self) -> &Self::Target {
30+
&self.0
31+
}
32+
}
33+
34+
#[cfg(feature = "idl-build")]
35+
impl anchor_lang::IdlBuild for SplMultisig {}

0 commit comments

Comments
 (0)