Rebuilding my 2021 Ethereum dApp on Solana with Helius
In 2021 I wrote a small Ethereum dApp called AddressBook. You picked an alias like @teezzan, the contract mapped it to your address, and anyone could send you ETH by alias instead of pasting 0x3e0f…b418. It was a Solidity contract on Hardhat with a React frontend, and the UI borrowed the layout of Uniswap’s swap card.
I rebuilt it on Solana. This was my first time using Helius, so it’s also a record of what using Helius for the first time was like: what worked immediately, what the free plan does and doesn’t include, and where I got stuck. The friction log at the end lists real errors with the exact messages.
The new version does what the old one did and adds three things the 2021 version never had:
- Holdings: SOL, tokens with USD value, and NFTs, from one DAS call.
- History in English: “X transferred 409.91 USDC to Y” instead of a list of signatures.
- A live feed: new transactions for the address appear about a second after they confirm.
Code: program/ (Anchor) and web/ (Next.js). The README has the setup steps.
Part 1: The mental model shift
Ethereum: the contract owns its storage
This is the whole state of the 2021 contract:
mapping(address => string) public addressToAlias;
mapping(string => address) public aliasToAddress;
function addAlias(string memory _alias) public {
require(aliasToAddress[_alias] == address(0), "Alias Taken");
delete aliasToAddress[addressToAlias[msg.sender]];
addressToAlias[msg.sender] = _alias;
aliasToAddress[_alias] = msg.sender;
}
The contract is the database. The mappings sit inside the contract’s storage, and callers don’t need to know where.
Solana: programs are stateless, state is accounts
A Solana program has no storage of its own. All data lives in accounts, and a transaction must list every account it will read or write before it runs. That list is how the runtime runs non-overlapping transactions in parallel, and it’s also why you can’t write mapping(string => address) and have the storage appear for you.
The replacement is a Program Derived Address (PDA): an account address computed deterministically from some seeds and the program ID. Each mapping entry becomes its own small account at a predictable address:
aliasToAddress["teezzan"] → account at PDA(["alias", "teezzan"]) holding { owner }
addressToAlias[0xabc…] → account at PDA(["owner", pubkey]) holding { alias }
In Anchor that looks like this:
#[account]
#[derive(InitSpace)]
pub struct AliasRecord {
pub owner: Pubkey,
#[max_len(32)]
pub alias: String,
pub bump: u8,
}
#[derive(Accounts)]
#[instruction(alias: String)]
pub struct Register<'info> {
#[account(mut)]
pub owner: Signer<'info>,
#[account(
init,
payer = owner,
space = 8 + AliasRecord::INIT_SPACE,
seeds = [b"alias", alias.as_bytes()],
bump
)]
pub alias_record: Account<'info, AliasRecord>,
#[account(
init,
payer = owner,
space = 8 + OwnerRecord::INIT_SPACE,
seeds = [b"owner", owner.key().as_ref()],
bump
)]
pub owner_record: Account<'info, OwnerRecord>,
pub system_program: Program<'info, System>,
}
Some things follow from this that I didn’t expect:
“Alias Taken” needs no code. The Solidity version needed require(aliasToAddress[_alias] == address(0)). Here init fails if the account already exists, so a second registration of teezzan fails on its own. The error you get is the System Program’s already in use, so the frontend translates it back into the 2021 message.
A lookup is a computation plus one read. No contract call and no ABI. The client derives the address and fetches the account:
export function aliasPda(alias: string): PublicKey {
return PublicKey.findProgramAddressSync(
[Buffer.from("alias"), Buffer.from(alias)],
PROGRAM_ID,
)[0];
}
The seed length caps the alias. A single PDA seed is at most 32 bytes. In Solidity the alias was an unbounded string; here it’s at most 32 bytes. I also restricted aliases to lowercase [a-z0-9_-], so @Teezzan and @teezzan can’t belong to two different people.
You pay rent for storage, and you get it back. Creating the two accounts locks a rent-exempt deposit of 0.0026 SOL (77 + 45 bytes). When you release your alias, Anchor’s close = owner returns all of it. My LiteSVM test for this checks that registering and then releasing costs only transaction fees:
#[account(mut, close = owner, has_one = owner,
seeds = [b"alias", owner_record.alias.as_bytes()], bump = alias_record.bump)]
pub alias_record: Account<'info, AliasRecord>,
Changing an alias: composition instead of one big function
addAlias in 2021 deleted your old alias and set the new one in a single function. On Solana I kept the program small (register, release, pay) and do the swap on the client by putting both instructions in one transaction:
const ixs = [];
if (currentAlias) ixs.push(await releaseIx(connection, owner, currentAlias));
ixs.push(await registerIx(connection, owner, newAlias));
return new Transaction().add(...ixs);
Transactions are atomic, so if register fails because the new alias is taken, release is rolled back and you keep your old alias. I hadn’t thought of transactions as a way to compose instructions before this, and I ended up liking it more than one large function.
Sending money by alias
deposit(alias) became pay(alias, lamports). The client resolves the alias and passes the recipient account. The program refuses any recipient that isn’t the alias owner:
#[account(mut, address = alias_record.owner @ AddressBookError::RecipientMismatch)]
pub recipient: SystemAccount<'info>,
A Solana transaction has to list every account it touches before it runs, so the client must name the recipient. The program’s job is to verify that name, and one of the tests checks that sending to the wrong recipient fails with RecipientMismatch.
Part 2: The read side, with Helius
A registry alone is a dull demo. Once you’ve resolved an alias to an address, you want to know who that is. This is where Helius comes in.
Rule zero: keep the key on the server
The Helius RPC URL has the API key in the query string (?api-key=…). If you put that in a NEXT_PUBLIC_ variable or point a browser Connection at it, the key ends up in your JavaScript bundle. So every Helius call in this app goes through a Next.js route handler:
browser → /api/holdings → Helius DAS
browser → /api/history → Enhanced Transactions API
browser → /api/live → (server holds the WebSocket) → Helius WSS
browser wallet → /api/rpc (allowlisted methods only) → Helius devnet RPC
The last route needed the most thought. Wallet adapters want a Connection, and a Connection wants an RPC URL. I pointed it at my own /api/rpc, which forwards only the methods a signing flow needs (getLatestBlockhash, sendTransaction, getSignatureStatuses, …) and rejects everything else. I grepped the production build output for the key and found zero matches.
Holdings: one DAS call
const r = await rpc("mainnet", "getAssetsByOwner", {
ownerAddress: owner,
page: 1,
limit: 1000,
options: { showFungible: true, showNativeBalance: true },
});
With showFungible, SPL tokens come back next to NFTs and compressed NFTs, and many of them include token_info.price_info. showNativeBalance adds SOL with a USD value. Without DAS, this panel would need getTokenAccountsByOwner, a metadata lookup per mint, and a price API. With DAS it’s one request.
History: transactions you can read
GET https://api.helius.xyz/v0/addresses/{address}/transactions?limit=20
Each result has a type (TRANSFER, SWAP, …), a source (SYSTEM_PROGRAM, JUPITER, …), and a description such as “5tzF…uAi9 transferred 10 USDT to 8R77…aG3b.”. I shorten the addresses and show it as is. For pagination, pass the last signature as before.
The live feed
The part I was most curious about was a live feed: open an address and watch new transactions arrive.
Helius has several real-time products. I tested each with my free-plan key:
- Standard WebSockets (
logsSubscribe,accountSubscribeonwss://mainnet.helius-rpc.com): worked. - Enhanced WebSockets (
transactionSubscribe):"transactionSubscribe is not available on the free plan". - LaserStream (gRPC):
"Unsupported plan type".
So the feed is built from logsSubscribe plus the Enhanced Transactions API. logsSubscribe with mentions: [address] sends a notification for every confirmed transaction that touches the address, but a notification only contains a signature and raw program logs. To get a readable line, I collect the signatures and send them in batches to POST /v0/transactions, which accepts up to 100 per call.
The server holds the WebSocket and passes events to the browser over Server-Sent Events, so the key stays on the server:
sock.send(JSON.stringify({
jsonrpc: "2.0", id: 1, method: "logsSubscribe",
params: [{ mentions: [address] }, { commitment: "confirmed" }],
}));
sock.onmessage = (ev) => {
const msg = JSON.parse(String(ev.data));
if (msg.method !== "logsNotification") return;
const { context, value } = msg.params.result;
pending.push(value.signature); // parse later, in batches
send("tx", { signature: value.signature, slot: context.slot, failed: value.err !== null });
};
// every 1.5s: up to 20 signatures → one Enhanced Transactions call → "parsed" events
In the browser, each row first appears as 4spJ1HQy… parsing… and changes to TRANSFER · 5tzF…uAi9 transferred 172.906 SOL to 9R7C…Q8wC about a second later. In my testing, the Enhanced Transactions API could already parse a transaction as soon as logsSubscribe reported it at confirmed, so I didn’t need a retry delay.
When the browser tab closes, the request’s AbortSignal fires and the server closes the upstream socket. I checked the server log to confirm the socket really closes.
A paid plan would let transactionSubscribe replace the two-step setup: it streams parsed transactions with richer filters. I’d still keep the server bridge so the key stays private.
One alias, two clusters
The registry runs on devnet, because nobody should spend real SOL to try a demo. Holdings, history and the live feed run on mainnet, because that’s where the activity is. This works because a Solana address is a public key, and the same keypair gives the same address on every cluster. So @teezzan on the devnet registry can point at mainnet activity.
Friction log
Everything below happened while I built this. I’ve included errors that weren’t Helius’s fault too, since they cost time all the same.
1. The Solana CLI installer failed on the first try.
sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)" →
Error: Unable to extract ".../download" ... failed to iterate over archive.
Running the same command again worked. I think the download was truncated.
2. anchor build failed with an invalid toolchain name.
error: invalid value '1.95.0-sbpf-solana-v1.57 /Users/…/.cache/solana/v1.57/platform-tools/rust'
for '<toolchain>...': invalid toolchain name
There’s a tab character inside the “toolchain name”: the build tool parsed rustup toolchain list -v output and expected a different format than my rustup 1.27.1 produced. Fix: rustup self update to 1.29.1. The error message doesn’t mention rustup’s version.
3. Anchor 1.2.1’s default SBPF target didn’t load in the test harness the template set up.
anchor build defaults to --arch v3. The workspace anchor init generated uses LiteSVM 0.10 for tests. My tests, built on that harness, all failed at svm.add_program(...) with Instruction(InvalidAccountData). Fix: anchor build --arch v0, after which all 7 tests passed. I only found the cause by checking e_flags in the ELF header (03 vs 00).
4. I couldn’t get devnet SOL, so the program isn’t on devnet yet.
solana airdrop 2→airdrop request failed. This can happen when the rate limit is reached., on the very first request from this wallet.- The public RPC’s raw response was clearer:
{"code": 429, "message": "You've either reached your airdrop limit today or the airdrop faucet has run dry. Please visit https://faucet.solana.com ..."} requestAirdropthrough the Helius devnet RPC returned an HTTP body ofInternal server error, not a JSON-RPC error. A message saying “airdrops aren’t proxied, use the faucet” would have saved me some guessing. Workaround: I ran a localsolana-test-validatorwith the program preloaded (--bpf-program) and ran the full write path against it through the app’s own RPC proxy: register, change alias, pay, and a wrong-recipient payment that must be rejected. Deploying to devnet is one script once the wallet has about 1.3 devnet SOL from faucet.solana.com.
5. On the free plan, a blocked Enhanced WebSocket failure can be confusing.
On wss://mainnet.helius-rpc.com, transactionSubscribe returns a clear JSON-RPC error: "transactionSubscribe is not available on the free plan". On the older wss://atlas-mainnet.helius-rpc.com endpoint that some examples still use, the handshake is rejected with HTTP 403, and Node’s WebSocket only reports Received network error or non-101 status code. I had to make the upgrade request with curl to see the actual body: "This feature is only available for developer plans or above." Note that the two endpoints also describe the plan requirement differently (“not on the free plan” vs “developer plans or above”).
6. The LaserStream SDK kept retrying a permanent error.
With helius-laserstream 0.12.0 and a free key, subscribe() logged
Connection failed (attempt 1/240): ... "Unsupported plan type" and kept counting up. My error callback never fired within 20 seconds. An authorization or plan error won’t fix itself on retry, so failing fast would be better here.
7. Enhanced Transactions API rate limits come sooner than DAS.
I fired 12 parallel requests at each API. DAS returned 12 × 200. /v0/addresses/…/transactions returned 429 for 4–5 of them, with a plain-text Too Many Requests body. That’s what decided the live-feed design: signatures are batched (≤20 per POST /v0/transactions, at most one call every 1.5s), REST calls retry with backoff, and on very busy addresses the feed shows rows unparsed rather than falling behind.
8. Some transactions have no description.
A Pump.fun transaction came back as "type": "UNKNOWN", "source": "PUMP_FUN", "description": "". The parser can’t describe every program, so the UI shows the type and source and says the parser gave no description.
9. DAS total is the size of the page, not the wallet.
getAssetsByOwner with limit: 1000 on the Binance hot wallet returned total: 1000. With limit: 2 it returned total: 2. My first UI said “1,000 assets total”, which was wrong. It now says “1,000+ assets (first DAS page)”.
10. A good error message (my mistake).
I mistyped the Jupiter program ID in my first logsSubscribe test and got Invalid Request: Invalid mentions provided immediately. That error was clear enough that I found the typo right away.
What I’d do next
- Deploy the registry to devnet (blocked only on faucet SOL) and let people try it with a real wallet in the browser. The wallet-signing UI is built, but so far I’ve only exercised that path from a script, not by clicking through Phantom.
- Try
transactionSubscribeon a paid plan and measure how much of the live-feed code it removes. - Use a webhook to index registry events, so “recently registered aliases” doesn’t need
getProgramAccounts.