Sitemap
Mahelikaa

Mahelikaa

A newbie in web development with a love for blockchain. Here to document my learning journey and share valuable insights with fellow tech enthusiasts!

Who Actually Controls a Solana Token?

9 min readOct 31, 2026

--

Week 1 of Turbin3, told through a Binance hack parody

Press enter or click to view image in full size

Binance reportedly lost around $6.3B this week. Early reports say it may have started with a compromised DevOps laptop. One laptop.

The same week I finished week 1 of the Turbin3 Q4 2026 Builders Cohort. I’ve built escrows, vaults, AMMs and exchange engines on Solana before, so I joined mostly for fun and to go back over the basics properly. The first assignment: make an SPL token, mint an NFT with Metaplex Core, then update it.

Given the timing, I made it a parody. Meet $BNBOOPS (Big Naughty Breach): a sweaty coin smashing the glass on a “SAFU fund” box, a laptop in quarantine and $3B sitting in a block of ice. The art alone took more drafts than the code.

There was one question on a slide in the first class that I kept thinking about while building this:

Where does the private key live and who is allowed to authorize a transaction with it?

That’s pretty much the Binance story in one line. Binance has since said it found more breach points beyond the laptop and that there’s no evidence any private key leaked. According to blockchain data firm Bitquery, Binance’s own withdrawal system signed the funds away from 17 hot wallets. So the reported compromise wasn’t necessarily about stealing private keys. It was about getting into the infrastructure that was authorized to use them.

To be clear: this wasn’t a Solana/blockchain hack. Binance is a centralized exchange and the breach was in its own internal systems. Solana is a public blockchain. I’m just borrowing this idea from the hack, which is that control comes down to who is authorized to sign, and using it as the thread for this write-up: who actually controls a token on Solana?

Everything is an account

On Solana, all state lives in accounts. Each one has a 32-byte address and five fields: lamports (its SOL balance), data, owner, executable and rent_epoch (which is basically a leftover now, set to u64::MAX for rent-exempt accounts).

The field that matters for this post is owner. It’s not a person. It’s the program allowed to modify the account’s data and debit its lamports, subject to the runtime’s rules. Other programs can still send lamports to it but generally only the owner can change what’s inside or take SOL out.

Programs are accounts too, just with executable set to true. They don’t keep state themselves, they write to accounts they own. And if a program was deployed with the upgradeable loader, whoever holds its upgrade authority can swap the code until that authority is revoked. So even “the code” has someone who controls it.

Instructions and transactions

An instruction tells one program to do one thing: which program, which accounts (and whether each one signs or gets written to), and some bytes of data. A transaction bundles instructions together with a signature from every required signer (fee payer first, and that first signature becomes the transaction ID) and a recent blockhash. The blockhash limits how long the transaction is valid (about 150 slots), and the runtime also tracks transactions it has already processed so it can reject duplicates.

Everything in a transaction succeeds or fails together. That’s called atomicity, and creating the $BNBOOPS mint shows why it’s useful. It’s two instructions:

// System Program: create the account and hand it to the Token Program
const createMintAccountIx = getCreateAccountInstruction({
payer: signer,
newAccount: mint,
lamports: mintRent,
space: mintSize,
programAddress: TOKEN_PROGRAM_ADDRESS,
});
// Token Program: initialize it as a mint
const initializeMintIx = getInitializeMintInstruction({
mint: mint.address,
decimals: 6,
mintAuthority: signer.address,
freezeAuthority: null,
});
const msg = createTransactionMessage({ version: 0 });
const withPayer = setTransactionMessageFeePayerSigner(signer, msg);
const withLifetime = setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, withPayer);
const tx = appendTransactionMessageInstructions([createMintAccountIx, initializeMintIx], withLifetime);
const signedTx = await signTransactionMessageWithSigners(tx);

There are two signers: my wallet (which pays the fee and the rent) and the new mint keypair, because a new account has to sign for its own creation. The System Program creates the account and assigns it to the Token Program and the Token Program sets it up. Since it’s one transaction, there’s no gap where someone else could grab the half-made account.

PDAs and CPIs

A PDA (program derived address) is derived from a program ID, some seeds and a bump, in a way that guarantees it’s off the Ed25519 curve. That means there’s no private key for it at all. Only the program that derived it can authorize it as a signer, and it does that during a cross-program invocation (CPI) by calling invoke_signed with the matching seeds and bump. A plain CPI with no PDA signer just uses invoke.

For the question I’m following, this is the interesting bit: a PDA is an authority with no private key at all. It can’t leak, because it doesn’t exist. Control sits entirely in the program’s logic, so the risk moves there instead.

Mint accounts

The Token Program has the rules for every token (create, mint, transfer, burn) but holds no balances. A mint account describes one token: its supply, decimals, mint authority (who can create more) and freeze authority (who can lock holders’ accounts).

Right after that first transaction, $BNBOOPS existed, with a supply of zero. Creating a token doesn’t give you any tokens. Minting is a separate step, and whoever holds the mint authority can do it again whenever they want. I set the freeze authority to null, so nobody can freeze anyone’s $BNBOOPS. I kept the mint authority, mostly so I could make a point about it later.

The name, symbol and logo come from a separate metadata account (Metaplex Token Metadata). Name, symbol and a URI go on-chain, and the URI points to a JSON file with the description and image, which I put on Irys:

const data: DataV2Args = {
name: "Big Naughty Breach",
symbol: "BNBOOPS",
uri: tokenUri,
sellerFeeBasisPoints: 0,
creators: null,
collection: null,
uses: null,
};

Token accounts and ATAs

Balances don’t sit in your wallet. They sit in token accounts, and each one stores a mint, an amount and an owner. This is where “owner” gets confusing, because it means two things. At the account level, every token account is owned by the Token Program. Inside the data, there’s a second owner field, which is your wallet. The Token Program checks that any transfer is authorized by that owner or by a delegate they’ve approved for a set amount. And the owner doesn’t have to be a person’s wallet: it can be a multisig or a PDA too.

The associated token account (ATA) is a PDA of the Associated Token Account program, derived from three seeds: your wallet address, the Token Program ID and the mint address. Same inputs always give the same address, so everyone agrees on where your $BNBOOPS lives. (The Token Program ID is a seed because the same wallet and mint would get a different ATA under Token-2022.)

const [toAta] = await findAssociatedTokenPda({
mint,
owner: recipient,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
const createAtaIx = await getCreateAssociatedTokenIdempotentInstructionAsync({
mint,
owner: recipient,
payer: signer,
ata: toAta,
});

I’ve done this plenty of times inside Anchor programs but writing it out client-side made the CPI chain obvious. The transaction logs show the ATA program calling the Token Program to work out the account size, the System Program to create the account, and then the Token Program again to initialize it. I used the idempotent version so I could re-run the script without it failing on an account that already exists.

Mint, transfer, burn

Mint adds to a balance and to total supply. Burn takes from both. Transfer only moves a balance from one token account to another, and supply stays the same.

Get Mahelikaa’s stories in your inbox

Join Medium for free to get updates from this writer.

I minted 1,000,000 $BNBOOPS (which is 1,000,000,000,000 base units with 6 decimals), then sent 100,000 to a second wallet using transferChecked, which also checks the mint and decimals:

const transferIx = getTransferCheckedInstruction({
source: fromAta,
mint,
destination: toAta,
authority: signer,
amount: 100_000n * 1_000_000n,
decimals: 6,
});

Afterwards I had 900,000, the other wallet had 100,000, and supply was still 1,000,000. So if a token’s supply ever goes up, someone with the mint authority did that.

A quick note on Token-2022

Token-2022 is a second token program that runs alongside the original and adds extensions: transfer fees, transfer hooks, built-in metadata, default account state and permanent delegates. I stayed on the original program with Metaplex metadata because everything supports it. With Token-2022’s metadata extensions configured, I could have stored the metadata directly in the mint instead of creating a separate metadata account, as long as the wallets and apps I cared about support it.

How Solana NFTs got here

The first NFT standard, Token Metadata, reused the fungible-token setup: a mint with a supply of one, a token account, and a metadata account. Three accounts for one thing. Programmable NFTs added enforced transfer rules, mostly to stop people dodging royalties. Compressed NFTs moved state into Merkle trees so you could mint millions cheaply. Metaplex Core is the newest, and it’s built only for NFTs.

Metaplex Core

A Core asset is one account. It holds the owner, the update authority, the name and the metadata URI. Less rent and a lot less to think about. Extra behaviour (royalties, freezing, delegates, custom logic) comes from plugins, which I didn’t need for this.

const asset = generateSigner(umi);
await create(umi, {
asset,
name: "BNBOOPS: Withdrawals Paused",
uri: metadataUri,
}).sendAndConfirm(umi);

Its traits matched the news at the time: Status: Investigating, Withdrawals: Paused, Laptop: Compromised, Loot: $3B Frozen.

Press enter or click to view image in full size
Before: Withdrawals Paused

Owning it vs controlling it

A Core asset has two separate roles: the owner, who holds it and can normally transfer it, and the update authority, who can change what it is. I’m both on this NFT. But if I’d sent it to you, I could still rewrite its metadata later. You’d own it, and you could still transfer it (unless a plugin like a freeze says otherwise), but you wouldn’t control what it says.

const asset = await fetchAsset(umi, assetAddress);
await update(umi, {
asset,
name: "BNBOOPS: Withdrawals Resumed",
uri: newUri,
}).sendAndConfirm(umi);
  • Name: Withdrawals Paused → Withdrawals Resumed
  • Status: Investigating → SAFU-ish
  • Withdrawals: Paused → Resumed
  • Laptop: Compromised → Quarantined
  • The Loot trait ($3B Frozen) got replaced by Users: Covered
Press enter or click to view image in full size
After: Withdrawals Resumed (same NFT, rewritten by the update authority)

I ended up rewriting the description about five times before I liked it. Then I wondered whether anyone could see the old versions. Partly. Explorers usually show the latest metadata, but the update transactions stay in the chain’s history. Whether the old JSON files stay reachable depends on where they’re stored. Mine are on Irys, which is meant to be permanent, but that’s a property of the storage, not of Solana.

So who controls a Solana token?

Whoever can sign as its authorities. Week 1 covered a lot of them:

  • the owner program of an account, the only thing that can change its data
  • a program’s upgrade authority, which can change the code until it’s revoked
  • a mint authority, which can print more supply (I still have mine, so $BNBOOPS is technically ruggable, although it’s also worth $0)
  • a freeze authority, which can lock holders’ tokens (mine is set to none)
  • an NFT’s update authority, which can rewrite its metadata after you buy it
  • PDAs, which are authorities with no private key at all

None of these are bad on their own. Update authority is how game items level up or a ticket turns into a collectible after the event. Revoking a mint or upgrade authority is how a project proves the supply or the code can’t change. PDAs are how programs hold funds without any person holding a key.

Back to Binance, with the same caveat as before: the hack was a break-in to a centralized exchange’s internal systems and none of the authorities above were involved. The only thing the two have in common is the question of authorization. Binance shows what happens when the wrong party gets control of what’s allowed to sign, even without stealing a key. An exchange keeps that control behind closed doors. On Solana, a lot more of it is public. A token’s mint and freeze authority or an NFT’s update authority take about ten seconds to check on an explorer and it’s worth doing before you buy anything. It’s not the whole picture, though. An authority field only shows an address and if that address is a PDA or a multisig, you have to look at the program or the signers behind it to know who really has control. Some authorities live elsewhere too, like a program’s upgrade authority or an NFT’s collection. But at least on Solana you can go and find out.

Which is probably the real takeaway. This started as a joke about a centralized exchange and it ended up being about decentralization, just not the version people usually argue about. Decentralized doesn’t mean nobody’s in control. $BNBOOPS lives on a decentralized chain and I alone can print more of it or rewrite its NFT. What’s different is that you can check who’s in control.

Links

See you in week 2.

--

--

Written by Mahelikaa

A newbie in web development with a love for blockchain. Here to document my learning journey and share valuable insights with fellow tech enthusiasts!