Skip to Content
On this page

On Ethereum, you wrap ETH into WETH because ETH isn’t an ERC-20 token. DEXs, lending markets, and other protocols that only speak ERC-20 need the wrapped version.

On DogeOS, that reason goes away. Native DOGE, the asset you pay gas with, also has a built-in ERC-20 interface. You don’t need to wrap DOGE.

What you can stop doing#

With native DOGE as an ERC-20, you can drop the WETH-era workarounds:

  • No wrap or unwrap transactions. Users don’t call deposit() before they trade, lend, or pay, or withdraw() afterward. A wrap, approve, and swap flow becomes approve and swap.
  • No separate native branch in your contracts. You don’t need a payable path for DOGE next to an ERC-20 path for everything else. DOGE is an ERC-20 token.
  • No WETH router helpers. You don’t need “swap exact ETH for tokens” variants or unwrapWETH steps. Your contract pulls DOGE with approve and transferFrom like any other token.

If your contract accepts both native DOGE and arbitrary ERC-20 tokens, read Count DOGE once before you deploy it.

Contract address#

ContractDogeOS Chikyū Testnet
Native DOGE token0x530000000000000000000000000000000000d09e

How it works#

The native DOGE token contract stores no balances. Its balanceOf(account) returns the native balance of account, and its transfer and transferFrom move native DOGE. The token calls a restricted transfer precompile at 0x00000000000000000000000000000000000000fd to do this. Only the token can call the precompile, so your contracts should always call the token.

As a result:

  • balanceOf(account) always equals account.balance and eth_getBalance(account).
  • A DOGE transfer through the token changes the native balance, and a native DOGE transfer changes balanceOf.

Interface#

The native DOGE token implements the core ERC-20 interface:

interface INativeDogeToken { event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); function name() external view returns (string memory); // "Dogecoin" function symbol() external view returns (string memory); // "DOGE" function decimals() external view returns (uint8); // 18 function totalSupply() external view returns (uint256); // fixed cap, not circulating supply function balanceOf(address account) external view returns (uint256); // == account.balance function allowance(address owner, address spender) external view returns (uint256); function approve(address spender, uint256 amount) external returns (bool); function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); }
PropertyBehavior
decimals()18, the same wei units as the native balance. Dogecoin’s 8-decimal satoshi unit applies only when you bridge to Dogecoin.
balanceOf(a)Always equals a.balance.
transfer/transferFromMove native DOGE and emit Transfer. They don’t run code at the recipient address.
transferFrom from yourselfUnlike WETH, requires an allowance even when from is the caller.
Unlimited allowanceAn allowance of type(uint256).max is never decreased by transferFrom.
address(0)transfer and transferFrom to address(0) revert, and approve reverts when the spender is address(0). A plain value transfer to address(0) succeeds.
GasA token transfer costs more gas than a plain value transfer, because it calls a contract and a precompile.
Not supporteddeposit()/withdraw(), permit (EIP-2612), increaseAllowance/decreaseAllowance, mint, and burn.
Sending DOGE to the tokenNo function is payable and there is no receive function, so a plain DOGE transfer to the token address reverts.

Failed calls revert with custom errors, such as ErrorInsufficientBalance, ErrorInsufficientAllowance, and ErrorTransferToZeroAddress.

Use native DOGE as an ERC-20#

Pull DOGE from a user in Solidity#

Your contract uses the standard ERC-20 pattern. The user approves your contract on the native DOGE token, then your contract pulls the DOGE:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; contract DogePayments { using SafeERC20 for IERC20; IERC20 public constant DOGE = IERC20(0x530000000000000000000000000000000000d09e); mapping(address => uint256) public credited; event Paid(address indexed payer, uint256 amount); /// The payer must first call DOGE.approve(address(this), amount). function pay(uint256 amount) external { // Moves native DOGE from msg.sender to this contract. DOGE.safeTransferFrom(msg.sender, address(this), amount); credited[msg.sender] += amount; emit Paid(msg.sender, amount); } }

After the call, address(this).balance grows by amount. Your contract can spend that DOGE with DOGE.safeTransfer(...) or with a native call{value: ...}.

Approve and read balances with viem#

import { createWalletClient, createPublicClient, http, erc20Abi, parseEther } from "viem" import { privateKeyToAccount } from "viem/accounts" const DOGE = "0x530000000000000000000000000000000000d09e" const SPENDER = "0xYourContract" const dogeosChikyu = { id: 6281971, name: "DogeOS Chikyū Testnet", nativeCurrency: { name: "Dogecoin", symbol: "DOGE", decimals: 18 }, rpcUrls: { default: { http: ["https://rpc.testnet.dogeos.com"] } }, } const account = privateKeyToAccount(process.env.PRIVATE_KEY) const publicClient = createPublicClient({ chain: dogeosChikyu, transport: http() }) const walletClient = createWalletClient({ account, chain: dogeosChikyu, transport: http() }) // Approve an exact amount. Avoid unlimited approvals on native DOGE. const hash = await walletClient.writeContract({ address: DOGE, abi: erc20Abi, functionName: "approve", args: [SPENDER, parseEther("100")], }) await publicClient.waitForTransactionReceipt({ hash }) // These two reads return the same balance. Use one, never their sum. const tokenBalance = await publicClient.readContract({ address: DOGE, abi: erc20Abi, functionName: "balanceOf", args: [account.address], }) const nativeBalance = await publicClient.getBalance({ address: account.address })

Integration considerations#

Native DOGE works like any ERC-20 token in most code. Account for these differences when you build contracts, wallets, or indexers.

Count DOGE once#

Double counting happens in integrations that treat native DOGE and the token as separate assets. Check your code for these patterns:

  • Contracts that add balances: a vault or treasury that adds address(this).balance to the balance of each allowlisted token reports double its DOGE if the native DOGE token is on the list.
  • Deposits inferred from balanceOf: by the time your function runs, msg.value is already in address(this).balance and in DOGE.balanceOf(address(this)). Don’t treat a before-and-after balanceOf difference as a separate deposit on top of msg.value.
  • Contracts that accept both msg.value and ERC-20 tokens: make sure one payment can’t be credited on both paths. Keep native DOGE and the native DOGE token on one ledger.
  • Native currency next to token addresses: if your contract represents native currency as address(0), treat address(0) and 0x5300…d09e as the same asset, or support only one of them.
  • Sweep helpers: a generic sweep(token) or rescueTokens(token) function called with the native DOGE token moves native DOGE. Make sure it can’t move DOGE your contract holds for users.
  • Wallets and dashboards: if a user adds 0x5300…d09e as a custom token, show it as the native DOGE balance, not as a second asset. Never add it to portfolio totals next to the native balance.
  • Indexers and accounting: record one DOGE ledger per account. Don’t keep a native ledger and a token ledger and add them.

Case study: Uniswap v4 on Celo#

Celo’s native asset, CELO, also has an ERC-20 interface, at 0x471EcE3750Da237f93B8E339c536989b8978a438. Before Uniswap v4 launched, OpenZeppelin’s audit of Uniswap v4 Core  (July 2024) found that its settlement logic could credit one CELO payment twice. They rated it critical (finding C-01), and Uniswap fixed it before launch in v4-core pull request #779 .

In the audited code, a caller paid its debt to the PoolManager with settle(currency). For the native currency (address(0)), settle credited msg.value. For an ERC-20 token, it credited the increase in the PoolManager’s balanceOf since the last sync(currency) call. On Celo, those are the same funds, so one payment could be counted twice:

  1. Call sync with the CELO token address. This records the PoolManager’s CELO balance.
  2. Call settle{value: 1000}(address(0)). The caller is credited 1000 in the native currency, and the PoolManager’s balance grows by 1000.
  3. Call settle with the CELO token address. The token’s balanceOf grew by 1000 in step 2, so the caller is credited another 1000.
  4. Call take for 1000 of the CELO token and take for 1000 of the native currency.

The fix made settlement strict: sync records one currency, settle() settles only that currency, and it reverts if msg.value is sent while an ERC-20 is synced.

Celo worked as designed. The bug was the contract’s assumption that native currency and an ERC-20 token are always different assets. If you deploy a fork of Uniswap v4, use a version of v4-core that includes pull request #779.

Balance changes without Transfer events#

The token emits Transfer only when someone calls its transfer or transferFrom functions. Most DOGE movements don’t go through the token, so they change balanceOf without any event from it:

  • Gas fees, deducted from the sender’s balance
  • Plain value transfers between accounts
  • Contract calls with msg.value, and contracts paying out DOGE with call{value: ...}
  • Bridge deposits and withdrawals, which move native DOGE

If you rebuild balances by summing Transfer logs from 0x5300…d09e, your numbers will be wrong. To track every balance change:

  • Read balances directly with eth_getBalance or balanceOf at a specific block.
  • Track native value transfers, including internal transfers from contract calls, with transaction traces, in addition to Transfer logs.
  • Account for gas fees from transaction receipts.
  • Reconcile periodically: compare your computed balance with eth_getBalance and investigate any difference.

Approvals can spend gas funds#

An approval on 0x5300…d09e lets the spender move native DOGE, including the DOGE you need for gas. Approve exact amounts, and revoke unused approvals with approve(spender, 0) while you still have DOGE to pay for the revoke. If you build a wallet, tell users that approving this token approves their gas balance.

Check and revoke approvals directly on the token:

import { erc20Abi } from "viem" const DOGE = "0x530000000000000000000000000000000000d09e" // Check how much `spender` can still move from `owner`. const remaining = await publicClient.readContract({ address: DOGE, abi: erc20Abi, functionName: "allowance", args: [owner, spender], }) // Revoke it. if (remaining > 0n) { await walletClient.writeContract({ address: DOGE, abi: erc20Abi, functionName: "approve", args: [spender, 0n], }) }

To find every spender, scan the token’s Approval events filtered by owner, then check each spender’s current allowance. The public RPC limits eth_getLogs to 10,000 blocks per request, so scan in chunks.

Contracts can receive DOGE without running code#

transfer and transferFrom don’t call the recipient’s receive or fallback function, so a contract can receive DOGE this way even if it rejects plain DOGE transfers. Don’t rely on receive() to record every incoming payment, and don’t assume address(this).balance equals the deposits your contract recorded.

Because the token never calls the recipient, a token transfer can’t re-enter your contract through the recipient. A native call{value: ...} still runs recipient code, so keep your existing reentrancy protections.

totalSupply() isn’t circulating supply#

totalSupply() returns a fixed protocol cap, not the amount of DOGE in circulation on DogeOS. Don’t use it for market cap, ownership percentages, or supply-based limits. Explorer holder counts are built from Transfer events, so they undercount DOGE holders.

Wrapped DOGE (wDOGE)#

A WETH-style Wrapped DOGE token (WDOGE) also exists on DogeOS Chikyū Testnet at 0x5300000000000000000000000000000000000004. It’s a separate token with its own balances, and it doesn’t convert automatically to or from native DOGE. Use it only if you have a strong reason, such as an existing integration that hardcodes the WETH deposit() and withdraw() pattern. For new contracts and integrations, use native DOGE at 0x5300…d09e.