Skip to content

Commit 6fd34a5

Browse files
committed
feat(solana/ift): add propose_admin validation guards and e2e edge case tests
- Reject proposing zero address, self-proposal and duplicate proposals - Add unit tests for all three new validation guards - Add e2e negative tests: unauthorized propose/accept, cancel with no pending, propose while pending exists, stale accept after cancel - Explicitly set pending_admin = None in initialize
1 parent 8b93c3b commit 6fd34a5

File tree

4 files changed

+273
-0
lines changed

4 files changed

+273
-0
lines changed

e2e/interchaintestv8/solana_ift_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1785,6 +1785,138 @@ func (s *IbcEurekaSolanaIFTTestSuite) Test_IFT_TwoStepAdminTransfer() {
17851785
s.Require().Equal(thirdAdminWallet.PublicKey(), state.Admin, "Admin should be the third admin")
17861786
s.Require().Nil(state.PendingAdmin, "Pending admin should be cleared")
17871787
}))
1788+
1789+
// Edge case: unauthorized propose (non-admin tries to propose)
1790+
s.Require().True(s.Run("Unauthorized propose is rejected", func() {
1791+
unauthorizedWallet, err := s.Solana.Chain.CreateAndFundWallet()
1792+
s.Require().NoError(err)
1793+
1794+
proposeIx, err := ift.NewProposeAdminInstruction(
1795+
unauthorizedWallet.PublicKey(),
1796+
s.IFTAppState,
1797+
unauthorizedWallet.PublicKey(), // not the admin
1798+
solanago.SysVarInstructionsPubkey,
1799+
)
1800+
s.Require().NoError(err)
1801+
1802+
tx, err := s.Solana.Chain.NewTransactionFromInstructions(unauthorizedWallet.PublicKey(), proposeIx)
1803+
s.Require().NoError(err)
1804+
1805+
_, err = s.Solana.Chain.SignAndBroadcastTxWithRetry(ctx, tx, rpc.CommitmentConfirmed, unauthorizedWallet)
1806+
s.Require().Error(err, "Non-admin should not be able to propose")
1807+
}))
1808+
1809+
// Edge case: cancel with no pending proposal
1810+
s.Require().True(s.Run("Cancel with no pending proposal is rejected", func() {
1811+
cancelIx, err := ift.NewCancelAdminProposalInstruction(
1812+
s.IFTAppState,
1813+
thirdAdminWallet.PublicKey(), // current admin
1814+
solanago.SysVarInstructionsPubkey,
1815+
)
1816+
s.Require().NoError(err)
1817+
1818+
tx, err := s.Solana.Chain.NewTransactionFromInstructions(thirdAdminWallet.PublicKey(), cancelIx)
1819+
s.Require().NoError(err)
1820+
1821+
_, err = s.Solana.Chain.SignAndBroadcastTxWithRetry(ctx, tx, rpc.CommitmentConfirmed, thirdAdminWallet)
1822+
s.Require().Error(err, "Cancel should fail when no pending proposal exists")
1823+
}))
1824+
1825+
// Set up a proposal so we can test more edge cases
1826+
var fourthAdminWallet *solanago.Wallet
1827+
s.Require().True(s.Run("Propose fourth admin for edge case tests", func() {
1828+
var err error
1829+
fourthAdminWallet, err = s.Solana.Chain.CreateAndFundWallet()
1830+
s.Require().NoError(err)
1831+
1832+
proposeIx, err := ift.NewProposeAdminInstruction(
1833+
fourthAdminWallet.PublicKey(),
1834+
s.IFTAppState,
1835+
thirdAdminWallet.PublicKey(),
1836+
solanago.SysVarInstructionsPubkey,
1837+
)
1838+
s.Require().NoError(err)
1839+
1840+
tx, err := s.Solana.Chain.NewTransactionFromInstructions(thirdAdminWallet.PublicKey(), proposeIx)
1841+
s.Require().NoError(err)
1842+
1843+
_, err = s.Solana.Chain.SignAndBroadcastTxWithRetry(ctx, tx, rpc.CommitmentConfirmed, thirdAdminWallet)
1844+
s.Require().NoError(err)
1845+
}))
1846+
1847+
// Edge case: unauthorized accept (non-pending wallet tries to accept)
1848+
s.Require().True(s.Run("Unauthorized accept is rejected", func() {
1849+
acceptIx, err := ift.NewAcceptAdminInstruction(
1850+
s.IFTAppState,
1851+
thirdAdminWallet.PublicKey(), // current admin, not the pending admin
1852+
solanago.SysVarInstructionsPubkey,
1853+
)
1854+
s.Require().NoError(err)
1855+
1856+
tx, err := s.Solana.Chain.NewTransactionFromInstructions(thirdAdminWallet.PublicKey(), acceptIx)
1857+
s.Require().NoError(err)
1858+
1859+
_, err = s.Solana.Chain.SignAndBroadcastTxWithRetry(ctx, tx, rpc.CommitmentConfirmed, thirdAdminWallet)
1860+
s.Require().Error(err, "Non-pending admin should not be able to accept")
1861+
}))
1862+
1863+
// Edge case: propose while pending already exists
1864+
s.Require().True(s.Run("Propose while pending exists is rejected", func() {
1865+
anotherWallet, err := s.Solana.Chain.CreateAndFundWallet()
1866+
s.Require().NoError(err)
1867+
1868+
proposeIx, err := ift.NewProposeAdminInstruction(
1869+
anotherWallet.PublicKey(),
1870+
s.IFTAppState,
1871+
thirdAdminWallet.PublicKey(), // current admin
1872+
solanago.SysVarInstructionsPubkey,
1873+
)
1874+
s.Require().NoError(err)
1875+
1876+
tx, err := s.Solana.Chain.NewTransactionFromInstructions(thirdAdminWallet.PublicKey(), proposeIx)
1877+
s.Require().NoError(err)
1878+
1879+
_, err = s.Solana.Chain.SignAndBroadcastTxWithRetry(ctx, tx, rpc.CommitmentConfirmed, thirdAdminWallet)
1880+
s.Require().Error(err, "Propose should fail when a pending proposal already exists")
1881+
}))
1882+
1883+
// Edge case: cancel then stale accept
1884+
s.Require().True(s.Run("Cancel the proposal", func() {
1885+
cancelIx, err := ift.NewCancelAdminProposalInstruction(
1886+
s.IFTAppState,
1887+
thirdAdminWallet.PublicKey(),
1888+
solanago.SysVarInstructionsPubkey,
1889+
)
1890+
s.Require().NoError(err)
1891+
1892+
tx, err := s.Solana.Chain.NewTransactionFromInstructions(thirdAdminWallet.PublicKey(), cancelIx)
1893+
s.Require().NoError(err)
1894+
1895+
_, err = s.Solana.Chain.SignAndBroadcastTxWithRetry(ctx, tx, rpc.CommitmentConfirmed, thirdAdminWallet)
1896+
s.Require().NoError(err)
1897+
}))
1898+
1899+
s.Require().True(s.Run("Stale accept after cancel is rejected", func() {
1900+
acceptIx, err := ift.NewAcceptAdminInstruction(
1901+
s.IFTAppState,
1902+
fourthAdminWallet.PublicKey(), // was the pending admin before cancel
1903+
solanago.SysVarInstructionsPubkey,
1904+
)
1905+
s.Require().NoError(err)
1906+
1907+
tx, err := s.Solana.Chain.NewTransactionFromInstructions(fourthAdminWallet.PublicKey(), acceptIx)
1908+
s.Require().NoError(err)
1909+
1910+
_, err = s.Solana.Chain.SignAndBroadcastTxWithRetry(ctx, tx, rpc.CommitmentConfirmed, fourthAdminWallet)
1911+
s.Require().Error(err, "Previously-pending admin should not be able to accept after cancel")
1912+
}))
1913+
1914+
// Verify state unchanged after all negative tests
1915+
s.Require().True(s.Run("Verify admin unchanged after edge case tests", func() {
1916+
state := readAppState()
1917+
s.Require().Equal(thirdAdminWallet.PublicKey(), state.Admin, "Admin should still be the third admin")
1918+
s.Require().Nil(state.PendingAdmin, "Pending admin should be nil")
1919+
}))
17881920
}
17891921

17901922
// Test_IFT_ExistingToken_InvalidPendingTransfer tests that ift_transfer rejects a wrong pending_transfer PDA

programs/solana/programs/ift/src/errors.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ pub enum IFTError {
114114

115115
#[msg("Signer is not the pending admin")]
116116
UnauthorizedPendingAdmin,
117+
118+
#[msg("Cannot propose the zero address as admin")]
119+
InvalidProposedAdmin,
120+
121+
#[msg("Cannot propose the current admin as the new admin")]
122+
SelfProposal,
123+
124+
#[msg("A pending admin proposal already exists; cancel it first")]
125+
PendingAdminAlreadyExists,
117126
}
118127

119128
impl From<CpiValidationError> for IFTError {

programs/solana/programs/ift/src/instructions/admin.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ pub struct ProposeAdmin<'info> {
3939
pub fn propose_admin(ctx: Context<ProposeAdmin>, new_admin: Pubkey) -> Result<()> {
4040
reject_cpi(&ctx.accounts.instructions_sysvar, &crate::ID).map_err(IFTError::from)?;
4141

42+
require!(
43+
new_admin != Pubkey::default(),
44+
IFTError::InvalidProposedAdmin
45+
);
46+
require!(
47+
new_admin != ctx.accounts.admin.key(),
48+
IFTError::SelfProposal
49+
);
50+
require!(
51+
ctx.accounts.app_state.pending_admin.is_none(),
52+
IFTError::PendingAdminAlreadyExists
53+
);
54+
4255
ctx.accounts.app_state.pending_admin = Some(new_admin);
4356

4457
let clock = Clock::get()?;
@@ -466,6 +479,123 @@ mod tests {
466479
);
467480
}
468481

482+
#[test]
483+
fn test_propose_admin_zero_address_rejected() {
484+
let mollusk = setup_mollusk();
485+
486+
let admin = Pubkey::new_unique();
487+
let (app_state_pda, app_state_bump) = get_app_state_pda();
488+
let (sysvar_id, sysvar_account) = create_instructions_sysvar_account();
489+
490+
let app_state_account = create_ift_app_state_account(app_state_bump, admin);
491+
492+
let instruction = Instruction {
493+
program_id: crate::ID,
494+
accounts: vec![
495+
AccountMeta::new(app_state_pda, false),
496+
AccountMeta::new_readonly(admin, true),
497+
AccountMeta::new_readonly(sysvar_id, false),
498+
],
499+
data: crate::instruction::ProposeAdmin {
500+
new_admin: Pubkey::default(),
501+
}
502+
.data(),
503+
};
504+
505+
let accounts = vec![
506+
(app_state_pda, app_state_account),
507+
(admin, create_signer_account()),
508+
(sysvar_id, sysvar_account),
509+
];
510+
511+
let result = mollusk.process_instruction(&instruction, &accounts);
512+
assert_eq!(
513+
result.program_result,
514+
Err(solana_sdk::instruction::InstructionError::Custom(
515+
ANCHOR_ERROR_OFFSET + IFTError::InvalidProposedAdmin as u32,
516+
))
517+
.into(),
518+
);
519+
}
520+
521+
#[test]
522+
fn test_propose_admin_self_proposal_rejected() {
523+
let mollusk = setup_mollusk();
524+
525+
let admin = Pubkey::new_unique();
526+
let (app_state_pda, app_state_bump) = get_app_state_pda();
527+
let (sysvar_id, sysvar_account) = create_instructions_sysvar_account();
528+
529+
let app_state_account = create_ift_app_state_account(app_state_bump, admin);
530+
531+
let instruction = Instruction {
532+
program_id: crate::ID,
533+
accounts: vec![
534+
AccountMeta::new(app_state_pda, false),
535+
AccountMeta::new_readonly(admin, true),
536+
AccountMeta::new_readonly(sysvar_id, false),
537+
],
538+
data: crate::instruction::ProposeAdmin { new_admin: admin }.data(),
539+
};
540+
541+
let accounts = vec![
542+
(app_state_pda, app_state_account),
543+
(admin, create_signer_account()),
544+
(sysvar_id, sysvar_account),
545+
];
546+
547+
let result = mollusk.process_instruction(&instruction, &accounts);
548+
assert_eq!(
549+
result.program_result,
550+
Err(solana_sdk::instruction::InstructionError::Custom(
551+
ANCHOR_ERROR_OFFSET + IFTError::SelfProposal as u32,
552+
))
553+
.into(),
554+
);
555+
}
556+
557+
#[test]
558+
fn test_propose_admin_pending_already_exists() {
559+
let mollusk = setup_mollusk();
560+
561+
let admin = Pubkey::new_unique();
562+
let first_proposed = Pubkey::new_unique();
563+
let second_proposed = Pubkey::new_unique();
564+
let (app_state_pda, app_state_bump) = get_app_state_pda();
565+
let (sysvar_id, sysvar_account) = create_instructions_sysvar_account();
566+
567+
let app_state_account =
568+
create_ift_app_state_account_full(app_state_bump, admin, false, Some(first_proposed));
569+
570+
let instruction = Instruction {
571+
program_id: crate::ID,
572+
accounts: vec![
573+
AccountMeta::new(app_state_pda, false),
574+
AccountMeta::new_readonly(admin, true),
575+
AccountMeta::new_readonly(sysvar_id, false),
576+
],
577+
data: crate::instruction::ProposeAdmin {
578+
new_admin: second_proposed,
579+
}
580+
.data(),
581+
};
582+
583+
let accounts = vec![
584+
(app_state_pda, app_state_account),
585+
(admin, create_signer_account()),
586+
(sysvar_id, sysvar_account),
587+
];
588+
589+
let result = mollusk.process_instruction(&instruction, &accounts);
590+
assert_eq!(
591+
result.program_result,
592+
Err(solana_sdk::instruction::InstructionError::Custom(
593+
ANCHOR_ERROR_OFFSET + IFTError::PendingAdminAlreadyExists as u32,
594+
))
595+
.into(),
596+
);
597+
}
598+
469599
#[test]
470600
fn test_accept_admin_success() {
471601
let mollusk = setup_mollusk();

programs/solana/programs/ift/src/instructions/initialize.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub fn initialize(ctx: Context<Initialize>, admin: Pubkey) -> Result<()> {
3030
app_state.version = AccountVersion::V1;
3131
app_state.bump = ctx.bumps.app_state;
3232
app_state.admin = admin;
33+
app_state.pending_admin = None;
3334

3435
let clock = Clock::get()?;
3536
emit!(IFTInitialized {
@@ -103,6 +104,7 @@ mod tests {
103104
let state = deserialize_app_state(&updated_account);
104105
assert_eq!(state.admin, admin);
105106
assert!(!state.paused);
107+
assert_eq!(state.pending_admin, None);
106108
}
107109

108110
#[test]

0 commit comments

Comments
 (0)