Skip to content

trustless-engineering/frozen-wUSDC

Repository files navigation

frozen-wusdc

frozen-wusdc is a Solana/Anchor program for issuing a delayed-redemption form of USDC.

The intended flow is:

  1. Alice deposits raw USDC into the program for Bob.
  2. The program mints wUSDC to Bob's Token-2022 account.
  3. That wUSDC is non-transferable and effectively soul-bound to Bob's address.
  4. After a configured slot delay, Bob can redeem it back into raw USDC.
  5. 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.

The Problem It Solves

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
  • wUSDC cannot 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.

Core Model

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 wUSDC ATA received the balance
  • how much was issued
  • the slot at which redemption becomes valid

The receipt is the on-chain authorization for later redemption.

Why This Helps Contain Hacks

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.

Program Flow

1. initialize

Creates:

  • the global config PDA
  • the Token-2022 wUSDC mint
  • the SPL USDC vault

The mint is configured as non-transferable and uses the config PDA as mint authority.

2. issue_wusdc

Alice provides:

  • her USDC source account
  • Bob's wallet address
  • a unique nonce

The program:

  • derives Bob's canonical wUSDC Token-2022 ATA
  • creates that ATA automatically if it does not already exist
  • transfers Alice's USDC into the vault
  • mints wUSDC 1:1 into Bob's wUSDC ATA
  • creates a FreezeReceipt
  • sets the receipt unlock slot

At this point Bob can see the wUSDC balance, but he cannot redeem it yet.

3. direct TransferChecked

Fails.

Because the mint uses Token-2022 NonTransferable, Bob cannot just forward wUSDC to Charlie.

4. unwrap

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 wUSDC ATA 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

5. close_receipt

After redemption, the claimed receipt can be closed and its rent returned to the treasury.

Security Properties

The current on-chain design enforces:

  • wUSDC is non-transferable.
  • wUSDC issuance is tied to a per-issuance receipt.
  • wUSDC redemption 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

What This Does Not Try To Solve

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.

Threat Model Fit

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

Admin Controls

The admin surface is intentionally small:

  • update_freeze_duration
  • set_treasury
  • set_paused
  • propose_authority
  • accept_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.

Integration Notes

  • USDC is standard SPL Token.
  • wUSDC is Token-2022.
  • wUSDC uses 6 decimals to match USDC.
  • the program auto-creates the recipient's canonical Token-2022 ATA for wUSDC if 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

Repository Layout

Local Development

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-sbf

Start 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.1

Deploy:

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.so

Run fast Rust tests:

cargo test-fast

Run the validator-backed suites:

bash scripts/ci/run-localnet-suite.sh

Project docs and operator entry points:

Network Deployment

This repo now supports the same program ID on:

  • localnet
  • devnet
  • testnet
  • mainnet

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.example to config/networks/devnet.env
  • copy config/networks/testnet.env.example to config/networks/testnet.env
  • copy config/networks/mainnet.env.example to config/networks/mainnet.env
  • fill in DEPLOY_WALLET, TREASURY, USDC_MINT, and FREEZE_SLOTS

Deploy only:

bash scripts/deploy/deploy-program.sh devnet

Initialize after deploy:

bash scripts/deploy/initialize-program.sh devnet

One-shot deploy and initialize:

bash scripts/deploy/full-deploy.sh devnet

Inspect the derived addresses or live config:

cargo run --example admin -- addresses --rpc-url https://api.devnet.solana.com
bash scripts/deploy/show-config.sh devnet

Live 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>

Devnet Deployment

  • program id: 5E8UrTuJjNk7u9fgVmsLFBD61FCzzwkPCaqzRogmhEjD
  • config PDA: 5JFFUEbbMSakCdU49jJHAqNMUT6kgdxtj1QASpSCtsZW
  • authority: 9DHxnTzPvYwkgz2rxgappTh8ZpergAMd1kT8GG5aKxuW
  • treasury: 9DHxnTzPvYwkgz2rxgappTh8ZpergAMd1kT8GG5aKxuW
  • USDC mint: 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU
  • wUSDC mint: FEVpweZ9Scv5NRmx2FYtzSZgYqV1jri6tt6fYXCa7cZi
  • vault: DQWmUNhALjsaNzbV5upBaNva5XyW8H3X15wTLsfRoPzT
  • freeze duration: 100 slots
  • 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 wUSDC transfer 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

Current Status

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

Donations are gladly accepted at 9DHxnTzPvYwkgz2rxgappTh8ZpergAMd1kT8GG5aKxuW.

About

time-locked wrapper for USDC transfers

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors