Skip to content
This repository was archived by the owner on Mar 11, 2025. It is now read-only.

Commit 49c7e7d

Browse files
authored
token-2022: Fix non-transferable extension for unchecked transfers (#4005)
1 parent 66b83a8 commit 49c7e7d

File tree

8 files changed

+104
-14
lines changed

8 files changed

+104
-14
lines changed

stake-pool/program/tests/helpers/mod.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,8 @@ pub async fn create_token_account(
246246
),
247247
ExtensionType::TransferFeeAmount
248248
| ExtensionType::MemoTransfer
249-
| ExtensionType::CpiGuard => (),
249+
| ExtensionType::CpiGuard
250+
| ExtensionType::NonTransferableAccount => (),
250251
_ => unimplemented!(),
251252
};
252253
}
@@ -288,7 +289,9 @@ pub async fn create_token_account(
288289
.unwrap(),
289290
)
290291
}
291-
ExtensionType::ImmutableOwner | ExtensionType::TransferFeeAmount => (),
292+
ExtensionType::ImmutableOwner
293+
| ExtensionType::TransferFeeAmount
294+
| ExtensionType::NonTransferableAccount => (),
292295
_ => unimplemented!(),
293296
}
294297
}

token/js/src/extensions/extensionType.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { IMMUTABLE_OWNER_SIZE } from './immutableOwner.js';
99
import { INTEREST_BEARING_MINT_CONFIG_STATE_SIZE } from './interestBearingMint/state.js';
1010
import { MEMO_TRANSFER_SIZE } from './memoTransfer/index.js';
1111
import { MINT_CLOSE_AUTHORITY_SIZE } from './mintCloseAuthority.js';
12-
import { NON_TRANSFERABLE_SIZE } from './nonTransferable.js';
12+
import { NON_TRANSFERABLE_SIZE, NON_TRANSFERABLE_ACCOUNT_SIZE } from './nonTransferable.js';
1313
import { PERMANENT_DELEGATE_SIZE } from './permanentDelegate.js';
1414
import { TRANSFER_FEE_AMOUNT_SIZE, TRANSFER_FEE_CONFIG_SIZE } from './transferFee/index.js';
1515

@@ -27,6 +27,7 @@ export enum ExtensionType {
2727
InterestBearingConfig,
2828
CpiGuard,
2929
PermanentDelegate,
30+
NonTransferableAccount,
3031
}
3132

3233
export const TYPE_SIZE = 2;
@@ -62,6 +63,8 @@ export function getTypeLen(e: ExtensionType): number {
6263
return INTEREST_BEARING_MINT_CONFIG_STATE_SIZE;
6364
case ExtensionType.PermanentDelegate:
6465
return PERMANENT_DELEGATE_SIZE;
66+
case ExtensionType.NonTransferableAccount:
67+
return NON_TRANSFERABLE_ACCOUNT_SIZE;
6568
default:
6669
throw Error(`Unknown extension type: ${e}`);
6770
}
@@ -83,6 +86,7 @@ export function isMintExtension(e: ExtensionType): boolean {
8386
case ExtensionType.ImmutableOwner:
8487
case ExtensionType.MemoTransfer:
8588
case ExtensionType.CpiGuard:
89+
case ExtensionType.NonTransferableAccount:
8690
return false;
8791
default:
8892
throw Error(`Unknown extension type: ${e}`);
@@ -96,6 +100,7 @@ export function isAccountExtension(e: ExtensionType): boolean {
96100
case ExtensionType.ImmutableOwner:
97101
case ExtensionType.MemoTransfer:
98102
case ExtensionType.CpiGuard:
103+
case ExtensionType.NonTransferableAccount:
99104
return true;
100105
case ExtensionType.Uninitialized:
101106
case ExtensionType.TransferFeeConfig:
@@ -117,17 +122,19 @@ export function getAccountTypeOfMintType(e: ExtensionType): ExtensionType {
117122
return ExtensionType.TransferFeeAmount;
118123
case ExtensionType.ConfidentialTransferMint:
119124
return ExtensionType.ConfidentialTransferAccount;
125+
case ExtensionType.NonTransferable:
126+
return ExtensionType.NonTransferableAccount;
120127
case ExtensionType.TransferFeeAmount:
121128
case ExtensionType.ConfidentialTransferAccount:
122129
case ExtensionType.CpiGuard:
123130
case ExtensionType.DefaultAccountState:
124131
case ExtensionType.ImmutableOwner:
125132
case ExtensionType.MemoTransfer:
126133
case ExtensionType.MintCloseAuthority:
127-
case ExtensionType.NonTransferable:
128134
case ExtensionType.Uninitialized:
129135
case ExtensionType.InterestBearingConfig:
130136
case ExtensionType.PermanentDelegate:
137+
case ExtensionType.NonTransferableAccount:
131138
return ExtensionType.Uninitialized;
132139
}
133140
}

token/js/src/extensions/nonTransferable.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { struct } from '@solana/buffer-layout';
2+
import type { Account } from '../state/account.js';
23
import type { Mint } from '../state/mint.js';
34
import { ExtensionType, getExtensionData } from './extensionType.js';
45

5-
/** Non-transferable state as stored by the program */
6+
/** Non-transferable mint state as stored by the program */
67
export interface NonTransferable {} // eslint-disable-line
78

9+
/** Non-transferable token account state as stored by the program */
10+
export interface NonTransferableAccount {} // eslint-disable-line
11+
812
/** Buffer layout for de/serializing an account */
913
export const NonTransferableLayout = struct<NonTransferable>([]);
1014

1115
export const NON_TRANSFERABLE_SIZE = NonTransferableLayout.span;
16+
export const NON_TRANSFERABLE_ACCOUNT_SIZE = NonTransferableLayout.span;
1217

1318
export function getNonTransferable(mint: Mint): NonTransferable | null {
1419
const extensionData = getExtensionData(ExtensionType.NonTransferable, mint.tlvData);
@@ -18,3 +23,12 @@ export function getNonTransferable(mint: Mint): NonTransferable | null {
1823
return null;
1924
}
2025
}
26+
27+
export function getNonTransferableAccount(account: Account): NonTransferableAccount | null {
28+
const extensionData = getExtensionData(ExtensionType.NonTransferableAccount, account.tlvData);
29+
if (extensionData !== null) {
30+
return NonTransferableLayout.decode(extensionData);
31+
} else {
32+
return null;
33+
}
34+
}

token/js/test/e2e-2022/nonTransferableMint.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe('nonTransferable', () => {
5757
expect(nonTransferable).to.not.be.null;
5858

5959
const owner = Keypair.generate();
60-
const accountLen = getAccountLen([ExtensionType.ImmutableOwner]);
60+
const accountLen = getAccountLen([ExtensionType.ImmutableOwner, ExtensionType.NonTransferableAccount]);
6161
const lamports = await connection.getMinimumBalanceForRentExemption(accountLen);
6262

6363
const sourceKeypair = Keypair.generate();

token/program-2022-test/tests/non_transferable.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use {
1616
};
1717

1818
#[tokio::test]
19-
async fn transfer_checked() {
19+
async fn transfer() {
2020
let test_transfer_amount = 100;
2121
let mut context = TestContext::new().await;
2222
context
@@ -27,6 +27,7 @@ async fn transfer_checked() {
2727
let TokenContext {
2828
mint_authority,
2929
token,
30+
token_unchecked,
3031
alice,
3132
bob,
3233
..
@@ -124,6 +125,28 @@ async fn transfer_checked() {
124125
)
125126
)))
126127
);
128+
129+
// regular unchecked transfer fails
130+
let error = token_unchecked
131+
.transfer(
132+
&bob_account,
133+
&alice_account,
134+
&bob.pubkey(),
135+
test_transfer_amount,
136+
&[&bob],
137+
)
138+
.await
139+
.unwrap_err();
140+
141+
assert_eq!(
142+
error,
143+
TokenClientError::Client(Box::new(TransportError::TransactionError(
144+
TransactionError::InstructionError(
145+
0,
146+
InstructionError::Custom(TokenError::NonTransferable as u32)
147+
)
148+
)))
149+
);
127150
}
128151

129152
#[tokio::test]
@@ -158,6 +181,7 @@ async fn transfer_checked_with_fee() {
158181
let TokenContext {
159182
mint_authority,
160183
token,
184+
token_unchecked,
161185
alice,
162186
bob,
163187
..
@@ -238,6 +262,28 @@ async fn transfer_checked_with_fee() {
238262
)))
239263
);
240264

265+
// unchecked transfer fails
266+
let error = token_unchecked
267+
.transfer(
268+
&alice_account,
269+
&bob_account,
270+
&alice.pubkey(),
271+
test_transfer_amount,
272+
&[&alice],
273+
)
274+
.await
275+
.unwrap_err();
276+
277+
assert_eq!(
278+
error,
279+
TokenClientError::Client(Box::new(TransportError::TransactionError(
280+
TransactionError::InstructionError(
281+
0,
282+
InstructionError::Custom(TokenError::NonTransferable as u32)
283+
)
284+
)))
285+
);
286+
241287
// self-transfer checked with fee fails
242288
let fee = transfer_fee.calculate_fee(test_transfer_amount).unwrap();
243289
let error = token

token/program-2022/src/extension/mod.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use {
1111
interest_bearing_mint::InterestBearingConfig,
1212
memo_transfer::MemoTransfer,
1313
mint_close_authority::MintCloseAuthority,
14-
non_transferable::NonTransferable,
14+
non_transferable::{NonTransferable, NonTransferableAccount},
1515
permanent_delegate::PermanentDelegate,
1616
transfer_fee::{TransferFeeAmount, TransferFeeConfig},
1717
},
@@ -531,6 +531,9 @@ impl<'data, S: BaseState> StateWithExtensionsMut<'data, S> {
531531
ExtensionType::TransferFeeAmount => {
532532
self.init_extension::<TransferFeeAmount>(true).map(|_| ())
533533
}
534+
ExtensionType::NonTransferableAccount => self
535+
.init_extension::<NonTransferableAccount>(true)
536+
.map(|_| ()),
534537
// ConfidentialTransfers are currently opt-in only, so this is a no-op for extra safety
535538
// on InitializeAccount
536539
ExtensionType::ConfidentialTransferAccount => Ok(()),
@@ -641,6 +644,8 @@ pub enum ExtensionType {
641644
CpiGuard,
642645
/// Includes an optional permanent delegate
643646
PermanentDelegate,
647+
/// Indicates that the tokens in this account belong to a non-transferable mint
648+
NonTransferableAccount,
644649
/// Padding extension used to make an account exactly Multisig::LEN, used for testing
645650
#[cfg(test)]
646651
AccountPaddingTest = u16::MAX - 1,
@@ -683,6 +688,7 @@ impl ExtensionType {
683688
ExtensionType::InterestBearingConfig => pod_get_packed_len::<InterestBearingConfig>(),
684689
ExtensionType::CpiGuard => pod_get_packed_len::<CpiGuard>(),
685690
ExtensionType::PermanentDelegate => pod_get_packed_len::<PermanentDelegate>(),
691+
ExtensionType::NonTransferableAccount => pod_get_packed_len::<NonTransferableAccount>(),
686692
#[cfg(test)]
687693
ExtensionType::AccountPaddingTest => pod_get_packed_len::<AccountPaddingTest>(),
688694
#[cfg(test)]
@@ -745,6 +751,7 @@ impl ExtensionType {
745751
| ExtensionType::TransferFeeAmount
746752
| ExtensionType::ConfidentialTransferAccount
747753
| ExtensionType::MemoTransfer
754+
| ExtensionType::NonTransferableAccount
748755
| ExtensionType::CpiGuard => AccountType::Account,
749756
#[cfg(test)]
750757
ExtensionType::AccountPaddingTest => AccountType::Account,
@@ -758,11 +765,13 @@ impl ExtensionType {
758765
pub fn get_required_init_account_extensions(mint_extension_types: &[Self]) -> Vec<Self> {
759766
let mut account_extension_types = vec![];
760767
for extension_type in mint_extension_types {
761-
#[allow(clippy::single_match)]
762768
match extension_type {
763769
ExtensionType::TransferFeeConfig => {
764770
account_extension_types.push(ExtensionType::TransferFeeAmount);
765771
}
772+
ExtensionType::NonTransferable => {
773+
account_extension_types.push(ExtensionType::NonTransferableAccount);
774+
}
766775
#[cfg(test)]
767776
ExtensionType::MintPaddingTest => {
768777
account_extension_types.push(ExtensionType::AccountPaddingTest);

token/program-2022/src/extension/non_transferable.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ use {
88
#[repr(transparent)]
99
pub struct NonTransferable;
1010

11+
/// Indicates that the tokens from this account belong to a non-transferable mint
12+
#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)]
13+
#[repr(transparent)]
14+
pub struct NonTransferableAccount;
15+
1116
impl Extension for NonTransferable {
1217
const TYPE: ExtensionType = ExtensionType::NonTransferable;
1318
}
19+
20+
impl Extension for NonTransferableAccount {
21+
const TYPE: ExtensionType = ExtensionType::NonTransferableAccount;
22+
}

token/program-2022/src/processor.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use {
1212
interest_bearing_mint::{self, InterestBearingConfig},
1313
memo_transfer::{self, check_previous_sibling_instruction_is_memo, memo_required},
1414
mint_close_authority::MintCloseAuthority,
15-
non_transferable::NonTransferable,
15+
non_transferable::{NonTransferable, NonTransferableAccount},
1616
permanent_delegate::{get_permanent_delegate, PermanentDelegate},
1717
reallocate,
1818
transfer_fee::{self, TransferFeeAmount, TransferFeeConfig},
@@ -287,6 +287,12 @@ impl Processor {
287287
if source_account.base.amount < amount {
288288
return Err(TokenError::InsufficientFunds.into());
289289
}
290+
if source_account
291+
.get_extension::<NonTransferableAccount>()
292+
.is_ok()
293+
{
294+
return Err(TokenError::NonTransferable.into());
295+
}
290296
let (fee, maybe_permanent_delegate) = if let Some((mint_info, expected_decimals)) =
291297
expected_mint_info
292298
{
@@ -297,10 +303,6 @@ impl Processor {
297303
let mint_data = mint_info.try_borrow_data()?;
298304
let mint = StateWithExtensions::<Mint>::unpack(&mint_data)?;
299305

300-
if mint.get_extension::<NonTransferable>().is_ok() {
301-
return Err(TokenError::NonTransferable.into());
302-
}
303-
304306
if expected_decimals != mint.base.decimals {
305307
return Err(TokenError::MintDecimalsMismatch.into());
306308
}

0 commit comments

Comments
 (0)