Back to the faucet

Using $PLUMBUS

PClub x EF: Privacy Infrastructure & Confidential Systems

$PLUMBUS is the shared test asset for the buildathon. Every submission uses the same token, on the same network, so every track speaks the same currency. It is a standard ERC-20 on Base Sepolia, it is worth exactly nothing, and there is plenty for everyone.

This doc takes you from "I have PLUMBUS in my wallet" to "I am using it in my project." If you have never touched an ERC-20 before, read it top to bottom once. Nothing here assumes prior on-chain experience, only that you can program.


1. 60-second quickstart

  1. Install MetaMask if you do not have it, and copy your wallet address (it looks like 0x1FC6...6201).
  2. Go to buildaplumbus.pclub.in.
  3. Paste your address, or click Connect wallet and pick your wallet to fill it in automatically. If you have several wallets installed you get a list to choose from.
  4. Click Get gas + PLUMBUS. You will receive two transactions: a little Base Sepolia ETH to pay for gas, and 1,000 PLUMBUS to build with.
  5. Click Add PLUMBUS to <your wallet> so the token shows up in your wallet.
  6. Switch MetaMask to the Base Sepolia network. Your balance is there.

That is it. You now have gas and tokens. Everything below is detail you can come back to when you need it.

The faucet gives you gas as well as tokens, so you can start from a completely empty wallet. This is the only step in the whole buildathon that works that way; after this you pay your own gas out of what it gave you.


2. The essentials

The values you will copy over and over:

What Value
Token name Plumbus
Symbol PLUMBUS
Decimals 18
Token address 0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5
Network Base Sepolia
Chain ID 84532
RPC URL https://sepolia.base.org
Currency symbol (gas) ETH
Explorer https://sepolia.basescan.org
Faucet https://buildaplumbus.pclub.in/

Links:

Copy-paste block:

PLUMBUS_ADDRESS=0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5
CHAIN_ID=84532
RPC_URL=https://sepolia.base.org
EXPLORER=https://sepolia.basescan.org

About those 18 decimals

This trips up literally everyone once, so get it out of the way now.

Solidity has no decimal numbers. Token balances are integers, and the token declares how many of those integer units make up one whole token. PLUMBUS uses 18, the ERC-20 default, so:

1 PLUMBUS      = 1000000000000000000        (1e18)
1000 PLUMBUS   = 1000000000000000000000     (1e21)
0.5 PLUMBUS    = 500000000000000000         (5e17)

When a balance looks absurdly large, you are looking at base units. Never write those zeros by hand; every library has a helper (parseUnits / formatUnits in viem and alloy, 1000 ether in Solidity, which just means 1000 * 10**18).


3. Getting gas + PLUMBUS

Primary: the faucet site

https://buildaplumbus.pclub.in/

Paste an address, click the button, receive two transactions: a gas drip of Base Sepolia ETH and 1,000 PLUMBUS. This is the recommended path because it is the only one that also gives you gas, so it works from a zero-balance wallet.

Limits, so you know what you are seeing if it refuses you:

The faucet is hosted on a free tier that sleeps when idle, so the first request after a quiet period can take up to a minute to wake up. If it seems to hang, give it one retry before assuming it is broken.

Fallback: call claim() yourself

The token has a built-in faucet. claim() mints 1,000 PLUMBUS directly to whoever calls it, and it works even if the faucet website is down. The catch: calling it is a transaction, so you need gas already. It cannot bootstrap an empty wallet.

Via Basescan, no tooling required:

  1. Open the contract's Write tab.
  2. Connect your wallet, make sure it is on Base Sepolia.
  3. Expand claim, click Write, confirm.

Via Foundry's cast:

cast send 0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5 "claim()" \
  --rpc-url https://sepolia.base.org \
  --private-key $PRIVATE_KEY

The cooldown is 6 hours per address. Your first claim is exempt, so a brand new address can always claim immediately. After that, an early retry reverts with:

CooldownActive(uint256 secondsRemaining)

Check before you spend gas on a doomed transaction:

cast call 0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5 \
  "claimCooldownRemaining(address)(uint256)" $YOUR_ADDRESS \
  --rpc-url https://sepolia.base.org
# 0 means you can claim right now

Backup gas sources

If you burn through the gas the faucet gave you, top up from a Base Sepolia ETH faucet. These give gas only, no PLUMBUS.

Coinbase Developer Platform faucet (recommended): https://portal.cdp.coinbase.com/products/faucet

Sign up for a free CDP account, then from the dashboard:

  1. Open Products in the sidebar and choose Onchain Tools.
  2. Go to Faucet.
  3. Set Network to Base Sepolia.
  4. Set Token to ETH.
  5. Paste your address and request.

Limits: 0.0001 ETH per request, up to 0.1 ETH per day.

That per-request amount is small on purpose. One request is about a fifth of what the $PLUMBUS faucet drips, so expect to click it a few times if you are genuinely out of gas. The daily cap is generous, the per-request one is not.

Other options: Base's list of network faucets, https://docs.base.org/base-chain/network-information/network-faucets

The gotcha that will cost you an hour

Base Sepolia ETH is NOT Ethereum Sepolia ETH

They are different tokens on different chains. Gas on Base Sepolia is paid in ETH that lives on Base Sepolia. If you use a normal L1 Sepolia faucet, that ETH lands on Ethereum Sepolia and cannot pay for anything here, no matter how much of it you have.

Symptoms: your wallet shows a healthy ETH balance, but every transaction fails with "insufficient funds". Check which network the balance is on.

Fix: use a Base Sepolia faucet (linked above), or bridge L1 Sepolia ETH across at https://sepolia-bridge.base.org. Bridging in this direction takes a couple of minutes.


4. Add PLUMBUS to your wallet

The steps below use MetaMask because it is the most common. Rabby, Phantom, and the rest have the same two settings under slightly different menus.

Step 1: add the Base Sepolia network

MetaMask ships with Base Sepolia in its network list once you enable test networks: Settings → Advanced → Show test networks, then pick Base Sepolia from the network dropdown.

If it is not there, add it manually with Add a custom network:

Field Value
Network name Base Sepolia
RPC URL https://sepolia.base.org
Chain ID 84532
Currency symbol ETH
Block explorer URL https://sepolia.basescan.org

Step 2: import the token

Wallets do not discover tokens on their own; you tell them what to display.

Tokens → Import tokens → Custom token, then:

Field Value
Token contract address 0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5
Token symbol PLUMBUS (auto-fills)
Token decimal 18 (auto-fills)

The one-click way

The faucet site has an Add PLUMBUS button, labelled with whichever wallet you connected, that does step 2 for you. It uses the EIP-747 wallet_watchAsset RPC method, which you can call from any dapp:

const options = {
  address: "0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5",
  symbol: "PLUMBUS",
  decimals: 18,
};

try {
  // EIP-747 specifies params as a bare object. MetaMask and Rabby want this.
  await provider.request({ method: "wallet_watchAsset", params: { type: "ERC20", options } });
} catch {
  // Phantom and some others follow the EIP-1193 convention that params is an
  // array, and reject the object form with "Missing or invalid parameters".
  await provider.request({ method: "wallet_watchAsset", params: [{ type: "ERC20", options }] });
}

Worth adding to your own project: it saves your judges and teammates a manual import. Do handle both shapes as above. We shipped the object-only version first and it broke for every Phantom user. Also pass a checksummed address, since a lowercase one is another common cause of that same parameter error.


5. Check your balance

Basescan (no tooling)

Open the token page, search your address, or go straight to:

https://sepolia.basescan.org/token/0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5?a=YOUR_ADDRESS

cast (Foundry)

cast call 0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5 \
  "balanceOf(address)(uint256)" 0xYOUR_ADDRESS \
  --rpc-url https://sepolia.base.org

Output looks like this:

1000000000000000000000 [1e21]

That is 1,000 PLUMBUS, not one sextillion. Convert it:

cast from-wei 1000000000000000000000
# 1000.000000000000000000

Also check your gas, since "no tokens" and "no gas" are different problems:

cast balance 0xYOUR_ADDRESS --rpc-url https://sepolia.base.org --ether

viem

import { createPublicClient, erc20Abi, formatUnits, http } from "viem";
import { baseSepolia } from "viem/chains";

const client = createPublicClient({
  chain: baseSepolia,
  transport: http("https://sepolia.base.org"),
});

const raw = await client.readContract({
  address: "0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5",
  abi: erc20Abi,
  functionName: "balanceOf",
  args: ["0xYOUR_ADDRESS"],
});

console.log(raw);                      // 1000000000000000000000n
console.log(formatUnits(raw, 18));     // "1000"

Note raw is a bigint, not a number. JavaScript's number cannot hold 1e21 without losing precision, which is why every Ethereum library uses bigint. Do arithmetic in bigint (a + b, a * 2n) and only convert to a string for display.


6. Using PLUMBUS in your project

This is the core section. Everything below is compiled and checked, not pseudocode.

Before the snippets, the one concept that trips up everyone:

A contract cannot reach into your wallet and take your tokens.

If a vault, pool, or router needs to move PLUMBUS on your behalf, you must first call approve(spender, amount) from your own wallet. That records an allowance. The contract then calls transferFrom(you, itself, amount), which spends against that allowance.

Two transactions, always in that order: approve, then the action. If you skip the approve, the second one reverts. This is the single most common bug in a first on-chain project, and it is not a bug in your code, it is a missing step.

Use transfer when you are sending tokens you hold. Use approve plus transferFrom when a contract needs to pull them from you.

6a. From a smart contract (Solidity ^0.8.24)

Declare the interface you need. You do not need the full OpenZeppelin dependency just to call a token:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

/// PLUMBUS adds a self-serve faucet on top of the standard ERC-20 surface.
interface IPlumbus is IERC20 {
    function claim() external;
    function claimCooldownRemaining(address account) external view returns (uint256);
    function lastClaim(address account) external view returns (uint256);
    function FAUCET_AMOUNT() external view returns (uint256);
    function CLAIM_COOLDOWN() external view returns (uint256);
}

Sending tokens the contract already holds:

contract SimpleSender {
    IPlumbus public constant PLUMBUS = IPlumbus(0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5);

    error TransferFailed();

    /// `amount` is in base units: 1 PLUMBUS == 1e18.
    function send(address to, uint256 amount) external {
        if (!PLUMBUS.transfer(to, amount)) revert TransferFailed();
    }

    function myBalance() external view returns (uint256) {
        return PLUMBUS.balanceOf(address(this));
    }
}

Pulling tokens from a user (the pattern you will actually use):

contract PlumbusVault {
    IPlumbus public constant PLUMBUS = IPlumbus(0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5);

    mapping(address => uint256) public deposits;

    event Deposited(address indexed who, uint256 amount);
    event Withdrawn(address indexed who, uint256 amount);

    error TransferFailed();
    error InsufficientAllowance(uint256 needed, uint256 actual);

    function deposit(uint256 amount) external {
        // Optional, but it turns a bare "transfer failed" into a useful message.
        uint256 allowed = PLUMBUS.allowance(msg.sender, address(this));
        if (allowed < amount) revert InsufficientAllowance(amount, allowed);

        if (!PLUMBUS.transferFrom(msg.sender, address(this), amount)) revert TransferFailed();

        deposits[msg.sender] += amount;
        emit Deposited(msg.sender, amount);
    }

    function withdraw(uint256 amount) external {
        // Solidity 0.8 reverts on underflow, so this also enforces the balance check.
        deposits[msg.sender] -= amount;

        if (!PLUMBUS.transfer(msg.sender, amount)) revert TransferFailed();
        emit Withdrawn(msg.sender, amount);
    }

    /// Test-harness trick: the vault claims its own 1,000 PLUMBUS from the token.
    /// `claim()` credits msg.sender, and msg.sender here is this contract, so the
    /// vault funds itself with no external transfer. Once per 6h.
    function fundSelf() external {
        PLUMBUS.claim();
    }

    /// Seconds until `fundSelf()` will work again. 0 means claimable now.
    function fundSelfCooldown() external view returns (uint256) {
        return PLUMBUS.claimCooldownRemaining(address(this));
    }
}

To use that vault from your wallet:

# 1. allow the vault to pull 500 PLUMBUS
cast send 0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5 \
  "approve(address,uint256)" $VAULT $(cast to-wei 500) \
  --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY

# 2. deposit
cast send $VAULT "deposit(uint256)" $(cast to-wei 500) \
  --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY

Contracts can self-fund with claim()

claim() has no restriction on who calls it, so a contract can call it on itself. msg.sender is the contract, so the 1,000 PLUMBUS is minted straight to it.

This is genuinely useful for test harnesses and demos: your pool, vault, or AMM can give itself starting capital with no deployment script, no pre-funding, and no transfer from your wallet.

// In a Foundry test:
function test_VaultSelfFunds() public {
    PlumbusVault vault = new PlumbusVault();
    vault.fundSelf();
    assertEq(PLUMBUS.balanceOf(address(vault)), 1_000 ether);
}

Remember the 6-hour cooldown applies per address, and a contract is an address. One self-fund per contract per 6 hours; deploy a fresh instance if you need more during testing.

6b. From TypeScript (viem)

npm i viem

A complete module. Reads, approves, transfers, claims:

import {
  createPublicClient,
  createWalletClient,
  custom,
  erc20Abi,
  formatUnits,
  http,
  parseUnits,
  type Address,
  type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia } from "viem/chains";

export const PLUMBUS: Address = "0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5";
export const DECIMALS = 18;

/** Reads need no wallet and no key. */
export const publicClient = createPublicClient({
  chain: baseSepolia,
  transport: http("https://sepolia.base.org"),
});

/** The two custom functions PLUMBUS adds on top of ERC-20. */
export const plumbusAbi = [
  { type: "function", name: "claim", inputs: [], outputs: [], stateMutability: "nonpayable" },
  {
    type: "function",
    name: "claimCooldownRemaining",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "view",
  },
] as const;

/** Balance in whole PLUMBUS, e.g. "1000". */
export async function getBalance(owner: Address): Promise<string> {
  const raw = await publicClient.readContract({
    address: PLUMBUS,
    abi: erc20Abi,
    functionName: "balanceOf",
    args: [owner],
  });

  // raw is a bigint of base units: 1000000000000000000000n === 1000 PLUMBUS
  return formatUnits(raw, DECIMALS);
}

/** How much `spender` is currently allowed to pull from `owner`. */
export async function getAllowance(owner: Address, spender: Address): Promise<string> {
  const raw = await publicClient.readContract({
    address: PLUMBUS,
    abi: erc20Abi,
    functionName: "allowance",
    args: [owner, spender],
  });

  return formatUnits(raw, DECIMALS);
}

/** Seconds until `owner` can call claim() again. 0 means claimable now. */
export async function getClaimCooldown(owner: Address): Promise<bigint> {
  return publicClient.readContract({
    address: PLUMBUS,
    abi: plumbusAbi,
    functionName: "claimCooldownRemaining",
    args: [owner],
  });
}

/** In a script: sign locally with a test private key. */
export function scriptWallet(privateKey: Hex) {
  return createWalletClient({
    account: privateKeyToAccount(privateKey),
    chain: baseSepolia,
    transport: http("https://sepolia.base.org"),
  });
}

/** In a dapp: sign with the user's browser wallet. */
export function browserWallet() {
  if (typeof window === "undefined" || !window.ethereum) {
    throw new Error("No injected wallet found");
  }

  return createWalletClient({
    chain: baseSepolia,
    transport: custom(window.ethereum),
  });
}

type Wallet = ReturnType<typeof scriptWallet>;

/** Send PLUMBUS you already hold. `amount` is human-readable, e.g. "12.5". */
export async function transfer(wallet: Wallet, to: Address, amount: string) {
  const hash = await wallet.writeContract({
    address: PLUMBUS,
    abi: erc20Abi,
    functionName: "transfer",
    args: [to, parseUnits(amount, DECIMALS)],
  });

  return publicClient.waitForTransactionReceipt({ hash });
}

/**
 * Let `spender` (a vault, pool, router) pull up to `amount` from you later.
 * Required before any contract can call transferFrom on your behalf.
 */
export async function approve(wallet: Wallet, spender: Address, amount: string) {
  const hash = await wallet.writeContract({
    address: PLUMBUS,
    abi: erc20Abi,
    functionName: "approve",
    args: [spender, parseUnits(amount, DECIMALS)],
  });

  return publicClient.waitForTransactionReceipt({ hash });
}

/** Self-serve faucet: mints 1,000 PLUMBUS to the caller. Once per 6 hours. */
export async function claim(wallet: Wallet) {
  const hash = await wallet.writeContract({
    address: PLUMBUS,
    abi: plumbusAbi,
    functionName: "claim",
    args: [],
  });

  return publicClient.waitForTransactionReceipt({ hash });
}

Using it:

const wallet = scriptWallet(process.env.PRIVATE_KEY as Hex);
const me = wallet.account.address;

console.log(await getBalance(me)); // "1000"

// Step 1: allow the vault to pull 500 PLUMBUS from you.
await approve(wallet, VAULT_ADDRESS, "500");

// Step 2: call your own vault's deposit, which does the transferFrom.
await wallet.writeContract({
  address: VAULT_ADDRESS,
  abi: vaultAbi, // your contract's ABI, not the token's
  functionName: "deposit",
  args: [parseUnits("500", 18)],
});

await transfer(wallet, recipient, "12.5");

parseUnits("12.5", 18) gives 12500000000000000000n. Do not build that number by hand, and do not use floating point anywhere near a token amount.

6c. From Rust (alloy)

[dependencies]
alloy = "2.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
eyre = "0.6"

The sol! macro turns Solidity signatures into type-safe Rust bindings at compile time. Get a signature wrong and your code will not build, which is exactly what you want:

use alloy::network::EthereumWallet;
use alloy::primitives::{address, utils::format_units, utils::parse_units, Address, U256};
use alloy::providers::{Provider, ProviderBuilder};
use alloy::signers::local::PrivateKeySigner;
use alloy::sol;
use eyre::Result;

/// $PLUMBUS on Base Sepolia (chain id 84532).
const PLUMBUS: Address = address!("18a0Ea0e27e906097d459E57a32dB07B7071c5F5");
const RPC_URL: &str = "https://sepolia.base.org";

sol! {
    #[sol(rpc)]
    interface IPlumbus {
        function balanceOf(address account) external view returns (uint256);
        function decimals() external view returns (uint8);
        function transfer(address to, uint256 amount) external returns (bool);
        function approve(address spender, uint256 amount) external returns (bool);
        function allowance(address owner, address spender) external view returns (uint256);
        function claim() external;
        function claimCooldownRemaining(address account) external view returns (uint256);
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    let signer: PrivateKeySigner = std::env::var("PRIVATE_KEY")?.parse()?;
    let me = signer.address();
    println!("wallet: {me}");

    let provider = ProviderBuilder::new()
        .wallet(EthereumWallet::from(signer))
        .connect_http(RPC_URL.parse()?);

    let plumbus = IPlumbus::new(PLUMBUS, &provider);

    // --- read -------------------------------------------------------------
    let decimals = plumbus.decimals().call().await?;
    let raw = plumbus.balanceOf(me).call().await?;
    println!("balance: {} PLUMBUS (raw {raw})", format_units(raw, decimals)?);

    // Gas balance, so you can tell "no tokens" from "no gas".
    let gas = provider.get_balance(me).await?;
    println!("gas:     {} ETH", format_units(gas, 18)?);

    // --- self-serve claim (optional) --------------------------------------
    let wait: U256 = plumbus.claimCooldownRemaining(me).call().await?;
    if wait.is_zero() {
        let receipt = plumbus.claim().send().await?.get_receipt().await?;
        println!("claimed 1000 PLUMBUS in {}", receipt.transaction_hash);
    } else {
        println!("claim on cooldown for {wait} more seconds");
    }

    // --- transfer ---------------------------------------------------------
    // parse_units turns "12.5" into 12500000000000000000. Never hand-roll this.
    let amount: U256 = parse_units("12.5", decimals)?.into();
    let to = address!("000000000000000000000000000000000000dEaD");

    let receipt = plumbus
        .transfer(to, amount)
        .send()
        .await?
        .get_receipt()
        .await?;
    println!("sent 12.5 PLUMBUS: {}", receipt.transaction_hash);

    // --- approve a spender (needed before it can transferFrom you) --------
    let spender = address!("000000000000000000000000000000000000BEEF");
    let receipt = plumbus
        .approve(spender, amount)
        .send()
        .await?
        .get_receipt()
        .await?;
    println!("approved: {}", receipt.transaction_hash);

    let allowance: U256 = plumbus.allowance(me, spender).call().await?;
    println!("allowance now {} PLUMBUS", format_units(allowance, decimals)?);

    Ok(())
}

Run it:

PRIVATE_KEY=0xyour_test_key cargo run

Two alloy details worth knowing:


7. Per-track integration recipes

Track 1: AI Agents x DeFi

PLUMBUS is the capital under management. The interesting engineering is the layer between the agent and the money, so the agent proposes an action and the contract decides whether it is allowed. The agent never holds the keys.

Give the vault the tokens, give the agent only the right to call a constrained function:

contract MandatedAgentVault {
    IPlumbus public constant PLUMBUS = IPlumbus(0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5);

    address public immutable owner;
    address public agent;
    uint256 public maxSpendPerTrade;
    mapping(address => bool) public whitelisted;

    error NotOwner();
    error NotAgent();
    error ExceedsMandate(uint256 requested, uint256 limit);
    error NotWhitelisted(address to);
    error TransferFailed();

    constructor(address agent_, uint256 maxSpendPerTrade_) {
        owner = msg.sender;
        agent = agent_;
        maxSpendPerTrade = maxSpendPerTrade_;
    }

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    modifier onlyAgent() {
        if (msg.sender != agent) revert NotAgent();
        _;
    }

    function setWhitelisted(address to, bool allowed) external onlyOwner {
        whitelisted[to] = allowed;
    }

    function setMaxSpendPerTrade(uint256 limit) external onlyOwner {
        maxSpendPerTrade = limit;
    }

    /// The agent proposes; the contract enforces. Even a fully compromised agent
    /// cannot exceed `maxSpendPerTrade` or reach a non-whitelisted address.
    function agentTransfer(address to, uint256 amount) external onlyAgent {
        if (amount > maxSpendPerTrade) revert ExceedsMandate(amount, maxSpendPerTrade);
        if (!whitelisted[to]) revert NotWhitelisted(to);

        if (!PLUMBUS.transfer(to, amount)) revert TransferFailed();
    }
}

Your demo is stronger if you show the agent trying to break its mandate and failing. Fund the vault with fundSelf() or a transfer, point your agent (LLM call, heuristic, whatever) at agentTransfer, and let a judge watch the guardrail hold.

Track 2: Zero-Knowledge Finance

PLUMBUS is the asset whose balance and movement you are hiding. The standard shape is a shielded pool: users deposit real tokens along with a commitment, then later withdraw by proving membership plus a nullifier, so deposits cannot be linked to withdrawals.

The deposit half is plain ERC-20 mechanics, approve then transferFrom:

contract ShieldedPoolDeposit {
    IPlumbus public constant PLUMBUS = IPlumbus(0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5);

    /// commitment => used. A real pool stores these in a Merkle tree.
    mapping(bytes32 => bool) public commitments;

    event Deposit(bytes32 indexed commitment, uint256 amount);

    error TransferFailed();
    error CommitmentUsed();

    /// `commitment` is typically hash(nullifier, secret), computed off-chain.
    function deposit(bytes32 commitment, uint256 amount) external {
        if (commitments[commitment]) revert CommitmentUsed();

        // Caller must approve() this pool first.
        if (!PLUMBUS.transferFrom(msg.sender, address(this), amount)) revert TransferFailed();

        commitments[commitment] = true;
        emit Deposit(commitment, amount);
    }
}

The ZK work lives in withdraw: verify a proof that the caller knows a secret behind some commitment in the tree, check the nullifier has not been spent, then transfer out to a fresh address. Fixed denominations (say, exactly 1,000 PLUMBUS per note, which is conveniently one faucet claim) make the anonymity set much stronger than arbitrary amounts, because amounts themselves leak linkage.

Track 3: Stealth Payments and Account Abstraction

PLUMBUS is what moves; the point is that an observer cannot tell who sent it or who can spend it. A stealth address is a fresh address derived from the recipient's published meta-address, so only they can compute the matching private key.

The transfer itself is ordinary. The privacy comes from where you send it:

// 1. Derive a one-time stealth address for the recipient (ERC-5564 style).
const stealthAddress = deriveStealthAddress(recipientMetaAddress, ephemeralPrivKey);

// 2. Send PLUMBUS there. On-chain this looks like any other transfer.
await transfer(wallet, stealthAddress, "1000");

// 3. Publish the ephemeral public key to an announcer contract so the recipient
//    can scan, detect the payment, and derive the spending key.
await announce(ephemeralPubKey, stealthAddress);

The catch you will hit immediately: a freshly derived stealth address has no gas, so the recipient cannot move the tokens out. That is exactly the problem ERC-4337 solves. Route the withdrawal through a smart account with a paymaster, or have a bundler sponsor it, so the recipient never needs to fund the stealth address from an identifiable wallet (which would undo the privacy you just built).

Bundling a PLUMBUS transfer into a UserOp callData:

import { encodeFunctionData, erc20Abi, parseUnits } from "viem";

const callData = encodeFunctionData({
  abi: erc20Abi,
  functionName: "transfer",
  args: [recipient, parseUnits("1000", 18)],
});

// callData goes into the UserOperation your smart account executes.

8. Contract reference

$PLUMBUS is a standard OpenZeppelin v5 ERC-20 plus a small self-serve faucet. Every standard ERC-20 method works exactly as you expect: transfer, approve, transferFrom, balanceOf, allowance, totalSupply, name, symbol, decimals, and the Transfer / Approval events.

The additions:

Member Signature Description
claim claim() Mints FAUCET_AMOUNT (1,000 PLUMBUS) to msg.sender. Once per CLAIM_COOLDOWN. First claim per address is exempt. Callable by contracts.
claimCooldownRemaining claimCooldownRemaining(address) → uint256 Seconds until that address can claim again. 0 means claimable now, including addresses that have never claimed.
lastClaim lastClaim(address) → uint256 Unix timestamp of that address's last claim(). 0 means never.
FAUCET_AMOUNT FAUCET_AMOUNT() → uint256 1000000000000000000000 (1,000 PLUMBUS).
CLAIM_COOLDOWN CLAIM_COOLDOWN() → uint256 21600 seconds (6 hours).
INITIAL_SUPPLY INITIAL_SUPPLY() → uint256 10000000000000000000000000 (10,000,000 PLUMBUS), minted at deploy.
ownerMint ownerMint(address,uint256) Owner only. Organizers use it to top up the faucet dispenser. You cannot call this; it reverts with OwnableUnauthorizedAccount for anyone else.

Event:

event FaucetClaim(address indexed to, uint256 amount);

Error:

error CooldownActive(uint256 secondsRemaining);

Custom error, not a revert string, so a raw failure shows as hex data rather than a message. cast and most tooling decode it automatically given the ABI.

Minimal ABI

Copy-paste for viem, ethers, web3.py, or anything else that wants JSON. Covers the common ERC-20 surface plus everything custom:

[
  {"type":"function","name":"name","inputs":[],"outputs":[{"name":"","type":"string"}],"stateMutability":"view"},
  {"type":"function","name":"symbol","inputs":[],"outputs":[{"name":"","type":"string"}],"stateMutability":"view"},
  {"type":"function","name":"decimals","inputs":[],"outputs":[{"name":"","type":"uint8"}],"stateMutability":"view"},
  {"type":"function","name":"totalSupply","inputs":[],"outputs":[{"name":"","type":"uint256"}],"stateMutability":"view"},
  {"type":"function","name":"balanceOf","inputs":[{"name":"account","type":"address"}],"outputs":[{"name":"","type":"uint256"}],"stateMutability":"view"},
  {"type":"function","name":"transfer","inputs":[{"name":"to","type":"address"},{"name":"value","type":"uint256"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable"},
  {"type":"function","name":"approve","inputs":[{"name":"spender","type":"address"},{"name":"value","type":"uint256"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable"},
  {"type":"function","name":"allowance","inputs":[{"name":"owner","type":"address"},{"name":"spender","type":"address"}],"outputs":[{"name":"","type":"uint256"}],"stateMutability":"view"},
  {"type":"function","name":"transferFrom","inputs":[{"name":"from","type":"address"},{"name":"to","type":"address"},{"name":"value","type":"uint256"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable"},
  {"type":"function","name":"claim","inputs":[],"outputs":[],"stateMutability":"nonpayable"},
  {"type":"function","name":"claimCooldownRemaining","inputs":[{"name":"account","type":"address"}],"outputs":[{"name":"","type":"uint256"}],"stateMutability":"view"},
  {"type":"function","name":"lastClaim","inputs":[{"name":"","type":"address"}],"outputs":[{"name":"","type":"uint256"}],"stateMutability":"view"},
  {"type":"function","name":"FAUCET_AMOUNT","inputs":[],"outputs":[{"name":"","type":"uint256"}],"stateMutability":"view"},
  {"type":"function","name":"CLAIM_COOLDOWN","inputs":[],"outputs":[{"name":"","type":"uint256"}],"stateMutability":"view"},
  {"type":"function","name":"ownerMint","inputs":[{"name":"to","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},
  {"type":"event","name":"Transfer","inputs":[{"name":"from","type":"address","indexed":true},{"name":"to","type":"address","indexed":true},{"name":"value","type":"uint256","indexed":false}],"anonymous":false},
  {"type":"event","name":"Approval","inputs":[{"name":"owner","type":"address","indexed":true},{"name":"spender","type":"address","indexed":true},{"name":"value","type":"uint256","indexed":false}],"anonymous":false},
  {"type":"event","name":"FaucetClaim","inputs":[{"name":"to","type":"address","indexed":true},{"name":"amount","type":"uint256","indexed":false}],"anonymous":false},
  {"type":"error","name":"CooldownActive","inputs":[{"name":"secondsRemaining","type":"uint256"}]}
]

9. Common pitfalls and fixes

"My transfer reverts and I do not know why." If a contract is moving tokens on your behalf, you almost certainly skipped approve. Check the allowance:

cast call 0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5 \
  "allowance(address,address)(uint256)" $YOU $SPENDER \
  --rpc-url https://sepolia.base.org

If it returns 0, call approve(spender, amount) from your wallet first. Approve is its own transaction and must be mined before the second one is sent.

"The amounts look insane. I asked for 1000 and got 1000000000000000000000." That is 18 decimals; you are seeing base units. 1000000000000000000000 is 1,000 PLUMBUS. Use formatUnits(raw, 18) to display and parseUnits("1000", 18) to send. Never hardcode the zeros.

"My transaction will not send. Insufficient funds." You need Base Sepolia ETH for gas, and it is not the same thing as L1 Sepolia ETH. See the gotcha in section 3. Check with:

cast balance $YOU --rpc-url https://sepolia.base.org --ether

If that is 0, get gas from the faucet or a Base Sepolia ETH faucet.

"claim() reverts." The 6-hour cooldown. Check exactly how long is left:

cast call 0x18a0Ea0e27e906097d459E57a32dB07B7071c5F5 \
  "claimCooldownRemaining(address)(uint256)" $YOU --rpc-url https://sepolia.base.org

Anything above 0 is seconds remaining. Use a different address if you need tokens now.

"The faucet website says I already have PLUMBUS." Working as intended: it refuses addresses that already hold 1,000 or more PLUMBUS, so one team cannot drain it. Spend what you have, or use a fresh address.

"Nothing works and my wallet looks fine." Check the network. Chain ID must be 84532. A wallet pointed at Ethereum Sepolia, Base mainnet, or anything else will show a zero balance and fail every transaction. In MetaMask the network name is in the top-left; verify it says Base Sepolia.

"My contract has no PLUMBUS in tests." Have it call claim() on itself. msg.sender is your contract, so it gets the 1,000 PLUMBUS directly. See section 6a.

"I have PLUMBUS but no gas, and the faucet will not give me more." This one is easy to misread. A faucet claim hands out gas and tokens together, and the faucet refuses any address already holding 1,000 or more PLUMBUS. So if you still hold your tokens but have burned through the gas, you get told you already have PLUMBUS when your actual problem is an empty tank. Top up gas on its own from the Coinbase Developer Platform faucet (see backup gas sources), which has no such restriction, or spend the tokens and claim again.

"My wallet does not show my tokens." Wallets do not auto-discover tokens. Import it once with the address and 18 decimals, or click the Add PLUMBUS button on the faucet. Your balance was always there; the wallet just was not displaying it.

"The faucet is slow or times out." It is on a free tier that sleeps when idle, so the first request after a quiet period takes up to a minute to cold-start. Retry once before assuming it is down.


10. Getting help

When you ask for help, paste the transaction hash (or the address and what you tried). A hash lets anyone open the transaction on Basescan and see the actual revert reason in about ten seconds, which turns a twenty-message thread into one reply.

Useful references:

Good luck. Go build something that would be uncomfortable to demo on mainnet.