Skip to content

Conversation

@abelmarnk
Copy link

Background

This PR adds the unwrap_lamports instruction originally proposed here to the Token-2022 program. It also addresses and closes issue #773.

Problem

In order to transfer out lamports from native SOL accounts, currently it is necessary to create and close ATAs or token accounts all the time.

Solution

This PR adds a new unwrap_lamports instruction that allows transferring out lamports directly to any destination account. This eliminates the need for creating temporary native token accounts for the recipient.

The new instruction uses the discriminator 45, which is currently unused in Token-2022.

The amount to unwrap is specified as an Option<u64>:

None: the entire token balance is unwrap
Some(amount): the specified amount is unwrap

Scope

  • Add instruction builder and associated tests
  • Add the POD type and helpers with tests
  • Add instruction processor associated tests
  • Update the Rust legacy test helpers
  • Update the Rust legacy tests
  • Update JS & JS legacy implementations
  • Update auto-generated code
  • Update CLI functionality and CLI tests

}

async fn wrapped_sol(test_validator: &TestValidator, payer: &Keypair) {
// both tests use the same ata so they can't run together
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both the original wrapped sol tests and the unwrap lamport tests create and use the payer native mint ata, so one may end up failing depending on the order the tests are run, so i put them here so they run in order.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine by me!

extensions: OptionOrNullable<Array<ExtensionArgs>>;
};

/** Gets the encoder for {@link MintArgs} account data. */
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments were generated when i ran the generate clients script

test_context
}

async fn make_context_with_native_mint(amount: u64) -> TestContext {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old tests generate new mints each time so i added this helper for the native mint

}
]
},
{
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually wrote this by hand, is this how it's usually done?
The origin of the file says shank, but i couldn't find any shank macros

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's been written by hand, thanks for handling this!

}

let self_transfer = source_account_info.key == destination_account_info.key;
if let Ok(cpi_guard) = source_account.get_extension::<CpiGuard>() {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the cpi guard since an unwrap is a transfer

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for thinking about other extensions 🙏

@abelmarnk
Copy link
Author

@joncinque 🙏 Could I get a high level review of this before I mark it ready for review?

Copy link
Contributor

@joncinque joncinque left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was in such good shape that I gave it a full review. Thanks for your contribution, and for making it so complete!

Most of the comments are pretty minor, so we should be able to land this pretty quickly.

cc @febo to also give it a look since he wrote the p-token implementation

signer_pubkeys: &[&Pubkey],
amount: Option<u64>,
) -> Result<Instruction, ProgramError> {
check_program_account(token_program_id)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since p-token will support this too, may as well relax the check to either token program:

Suggested change
check_program_account(token_program_id)?;
check_spl_token_program_account(token_program_id)?;

/// The amount of lamports to transfer. When an amount is
/// not specified, the entire balance of the source account will be
/// transferred.
#[cfg_attr(feature = "serde", serde(with = "coption_fromstr"))]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is ok, since generally we should do this more for u64 values, but we don't normally serialize amounts as strings.

}
]
},
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's been written by hand, thanks for handling this!

Comment on lines +79 to +89
// unwrap Some(1) lamports is ok
token
.unwrap_lamports(
&alice_account,
&bob_account,
&alice.pubkey(),
Some(1),
&[&alice],
)
.await
.unwrap();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we check that the lamport was moved?

Comment on lines +133 to +143
// unwrap None lamports is ok
token
.unwrap_lamports(
&alice_account,
&bob_account,
&alice.pubkey(),
None,
&[&alice],
)
.await
.unwrap();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we check that all of the lamports were moved and the account was deleted?

Comment on lines +2 to +3
import { PublicKey } from '@solana/web3.js';
import { Keypair } from '@solana/web3.js';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: may as well combine these imports

}

let self_transfer = source_account_info.key == destination_account_info.key;
if let Ok(cpi_guard) = source_account.get_extension::<CpiGuard>() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for thinking about other extensions 🙏

Comment on lines +1701 to +1704
// Revisit this later to see if it's worth adding a check to reduce
// compute costs, ie:
// if self_transfer || amount == 0
check_program_account(source_account_info.owner)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to check this in every case -- only if self_transfer || amount == 0 works

.checked_add(amount)
.ok_or(TokenError::Overflow)?;

source_account.base.amount = remaining_amount.into();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also present in the p-token implementation, and now I'm questioning it. We don't decrement the source_account.amount during a self-transfer, but it would definitely be clearer if we did. Meaning, if I unwrap from my wrapped SOL account into itself, I would imagine those lamports go from being part of amount to no longer being part of amount.

What do you think @febo ?

@joncinque joncinque requested a review from febo November 26, 2025 21:51
@abelmarnk
Copy link
Author

abelmarnk commented Nov 27, 2025

I've made the changes, i'll resolve the conversation with the commit references after the last comment(the one about the p-token implementation) has been resolved since it would affect most of the code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants