frozen-wusdc is a Solana/Anchor program for issuing a delayed-redemption form of USDC.
The intended flow is:
- Alice deposits raw USDC into the program for Bob.
- The program mints
wUSDCto Bob's Token-2022 account. - That
wUSDCis non-transferable and effectively soul-bound to Bob's address. - After a configured slot delay, Bob can redeem it back into raw USDC.
- Redemption is only allowed to Bob's canonical SPL USDC account.
This is designed for exchanges, custodians, and program-controlled settlement systems that want to slow post-hack fund movement without holding an entirely off-chain ledger.
After a compromise, attackers usually try to move funds quickly:
- across fresh wallets
- into exchange deposit rails
- into bridge flows
- onto other networks before anyone can respond
Plain USDC does not help with that. Once it hits the attacker's wallet, it is immediately transferable.
This program changes the shape of that workflow:
- the recipient gets
wUSDC, not freely transferable USDC wUSDCcannot be directly forwarded to another wallet- the holder must wait through an on-chain delay before redemption
- redemption is pinned to the intended recipient's own USDC account
That gives operators a deterministic response window after issuance.
The program uses a Token-2022 mint with the NonTransferable extension.
That gives wUSDC two important properties:
- it cannot be transferred wallet-to-wallet with ordinary token instructions
- it still behaves like a token balance for accounting and issuance purposes
Each issuance also creates a FreezeReceipt that records:
- who funded the issuance
- who the intended recipient is
- which
wUSDCATA received the balance - how much was issued
- the slot at which redemption becomes valid
The receipt is the on-chain authorization for later redemption.
If an exchange credits a user with plain USDC, the user can move it immediately.
If the exchange instead credits the user with wUSDC through this program:
- the balance is stuck to the user's address
- the user cannot forward it to a new wallet
- the user cannot hand it directly to a bridge as a transferable SPL asset
- the user cannot unwrap it early
- once the delay expires, the USDC can only be released to that same user's canonical USDC ATA
That does not make stolen funds disappear. It does make "get funds and immediately hop them somewhere else" materially harder.
Creates:
- the global config PDA
- the Token-2022
wUSDCmint - the SPL USDC vault
The mint is configured as non-transferable and uses the config PDA as mint authority.
Alice provides:
- her USDC source account
- Bob's wallet address
- a unique nonce
The program:
- derives Bob's canonical
wUSDCToken-2022 ATA - creates that ATA automatically if it does not already exist
- transfers Alice's USDC into the vault
- mints
wUSDC1:1 into Bob'swUSDCATA - creates a
FreezeReceipt - sets the receipt unlock slot
At this point Bob can see the wUSDC balance, but he cannot redeem it yet.
Fails.
Because the mint uses Token-2022 NonTransferable, Bob cannot just forward wUSDC to Charlie.
After the receipt unlock slot has passed, Bob can redeem.
The program verifies:
- the receipt is unlocked
- the receipt has not already been used
- the caller is the recorded recipient
- the caller is burning from the exact
wUSDCATA recorded in the receipt - the USDC destination is the caller's canonical SPL USDC ATA
Then it:
- burns the recorded amount of
wUSDC - releases the same amount of raw USDC from the vault
- marks the receipt claimed
After redemption, the claimed receipt can be closed and its rent returned to the treasury.
The current on-chain design enforces:
wUSDCis non-transferable.wUSDCissuance is tied to a per-issuance receipt.wUSDCredemption is delayed by slot count.- redemption is only available to the recorded recipient
- redemption is only paid to the recipient's canonical SPL USDC ATA
- a receipt can only be redeemed once
- the admin can pause new issuance
- authority transfer is two-step
This repo is about delayed, recipient-bound redemption. It is not a full compliance system.
It does not currently provide:
- blacklist or sanctions logic
- KYC or identity attestation
- per-recipient risk scoring
- operator approval queues for each redemption
- compatibility with arbitrary DeFi protocols that expect transferable tokens
It also does not stop Bob from using his own USDC after the unlock period. That is intentional.
This is a good fit when:
- an exchange wants to issue delayed-settlement balances on-chain
- a custodian wants a quarantine period before recipients receive liquid USDC
- a protocol wants "credit now, redeem later" behavior tied to the recipient address
This is not the right fit when:
- the asset must remain freely composable in DeFi while still being delayed
- recipients must be able to forward balances before redemption
- you need discretionary case-by-case approval instead of deterministic time locks
The admin surface is intentionally small:
update_freeze_durationset_treasuryset_pausedpropose_authorityaccept_authority
Pause behavior today:
- paused: blocks new
issue_wusdc - not blocked by pause: already-issued balances can still be redeemed once unlocked
So pause is an intake brake, not a confiscation switch.
- USDC is standard SPL Token.
wUSDCis Token-2022.wUSDCuses 6 decimals to match USDC.- the program auto-creates the recipient's canonical Token-2022 ATA for
wUSDCif needed - redemption pays only to the canonical SPL ATA for the recipient's USDC
- the delay is measured in Solana slots, not seconds
- the canonical program id across clusters is
5E8UrTuJjNk7u9fgVmsLFBD61FCzzwkPCaqzRogmhEjD
- lib.rs: on-chain program
- tests/localnet_smoke.rs: validator-backed core flow and negative-path tests
- tests/localnet_admin.rs: validator-backed admin and governance tests
- tests/common/mod.rs: shared Rust fixture and instruction builders
- TESTING.md: test tiers, production test policy, CI gates, and known gaps
- scripts/deploy: cluster-aware deploy, initialize, and inspection scripts
- config/networks: per-network deploy config templates
- tests/frozen-wusdc.ts: non-blocking Anchor-style integration sketch
- Anchor.toml: local Anchor config
The repo now treats Rust as the canonical test path.
Target runtime for automation:
- Agave / Solana CLI
3.1.13
The source of truth for that pin is scripts/ci/versions.sh. It should track the newest Agave release explicitly marked stable and suitable for Mainnet Beta.
This pin is a runtime and CI target, not a claim that the full crate graph has already been migrated to the newest Anchor or Solana SDK line. The program still carries older Anchor-era Rust dependencies and those must be requalified separately.
Build:
cargo build-sbfStart local validator:
mkdir -p .localnet
[[ -f .localnet/id.json ]] || solana-keygen new --no-bip39-passphrase --silent -o .localnet/id.json
solana-test-validator \
--ledger .localnet/ledger \
--reset \
--mint "$(solana-keygen pubkey .localnet/id.json)" \
--rpc-port 8899 \
--faucet-port 0 \
--bind-address 127.0.0.1Deploy:
solana program deploy \
--url http://127.0.0.1:8899 \
--keypair .localnet/id.json \
--program-id target/deploy/frozen_wusdc-keypair.json \
target/deploy/frozen_wusdc.soRun fast Rust tests:
cargo test-fastRun the validator-backed suites:
bash scripts/ci/run-localnet-suite.shProject docs and operator entry points:
TESTING.mdis the test and CI policyscripts/deploycontains the deploy, initialize, inspection, and operator workflowsconfig/networkscontains the per-network configuration templates
This repo now supports the same program ID on:
localnetdevnettestnetmainnet
That is an intentional constraint of the current codebase. The program ID is fixed in lib.rs, so the safe deployment model is one canonical program address reused across clusters, not a different address per cluster.
Per-network inputs live outside source control:
- copy
config/networks/devnet.env.exampletoconfig/networks/devnet.env - copy
config/networks/testnet.env.exampletoconfig/networks/testnet.env - copy
config/networks/mainnet.env.exampletoconfig/networks/mainnet.env - fill in
DEPLOY_WALLET,TREASURY,USDC_MINT, andFREEZE_SLOTS
Deploy only:
bash scripts/deploy/deploy-program.sh devnetInitialize after deploy:
bash scripts/deploy/initialize-program.sh devnetOne-shot deploy and initialize:
bash scripts/deploy/full-deploy.sh devnetInspect the derived addresses or live config:
cargo run --example admin -- addresses --rpc-url https://api.devnet.solana.com
bash scripts/deploy/show-config.sh devnetLive operator CLI:
bash scripts/deploy/operator.sh devnet issue --recipient <PUBKEY> --amount 1.25
bash scripts/deploy/operator.sh devnet check-receipt --sender <PUBKEY> --nonce <U64>
bash scripts/deploy/operator.sh devnet unwrap --sender <PUBKEY> --nonce <U64> --wallet "$HOME/.config/solana/id.json"
bash scripts/deploy/operator.sh devnet close-receipt --sender <PUBKEY> --nonce <U64>- program id:
5E8UrTuJjNk7u9fgVmsLFBD61FCzzwkPCaqzRogmhEjD - config PDA:
5JFFUEbbMSakCdU49jJHAqNMUT6kgdxtj1QASpSCtsZW - authority:
9DHxnTzPvYwkgz2rxgappTh8ZpergAMd1kT8GG5aKxuW - treasury:
9DHxnTzPvYwkgz2rxgappTh8ZpergAMd1kT8GG5aKxuW - USDC mint:
4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU - wUSDC mint:
FEVpweZ9Scv5NRmx2FYtzSZgYqV1jri6tt6fYXCa7cZi - vault:
DQWmUNhALjsaNzbV5upBaNva5XyW8H3X15wTLsfRoPzT - freeze duration:
100slots - paused:
false
Deployment transactions:
- program deploy signature:
aJ5t5weAUXDXqesGmbViorassziXffHTazzsUNLyPRM9gQe1PZkVLaDmZD3EqL9bwsuZKR3tpU1XdbBzAFuFtJB - initialize signature:
3z7gCUNasHqjCkvfJVsahuSE8wvK8oDbbEq6mCAVxMDcesuMuFjYXgqgwvLibqFE5v6AkB3GAY8tbLBFJT6k1gHY
The current validator-backed suites verify:
- initialize
- issue Alice USDC to Bob as
wUSDC - direct
wUSDCtransfer rejection - early unwrap rejection
- delayed unwrap to Bob's USDC ATA
- receipt close
- pause blocking new issuance but not delayed redemption
- authority handoff and stale-authority rejection
- freeze-duration and treasury updates
- rejection of non-canonical recipient and redemption destinations
The program has been exercised successfully on a fresh local validator with the exact flow above:
- Alice funds the issuance with USDC
- Bob receives non-transferable
wUSDC - Bob cannot transfer it away
- Bob cannot redeem early
- after the slot delay, Bob can redeem only into Bob's USDC account
That is the current security claim this repo is built to support.
Donations are gladly accepted at 9DHxnTzPvYwkgz2rxgappTh8ZpergAMd1kT8GG5aKxuW.