Skip to main content

Building Crypto Scripts: Stellar XLM & Ethereum

· 8 min read
Guillaume MARTINEZ
LeadTech DevOps & Cloud & IA

cover

I don't trust apps with my crypto. Every wallet, exchange, and dapp is a black box. You hand over your private key, click "send", and hope the code does what it claims. So I built my own scripts instead: small, auditable Python programs that I can read and understand completely.

The differences between Stellar and Ethereum force you to confront each blockchain's design philosophy. Here's what I learned building scripts for both.

Getting Started: Keypair Generation

The first difference hit me immediately: how do you fund a testnet account?

With Stellar, it's almost too easy. Generate a keypair, then call Friendbot once:

keypair = Keypair.random()
public_key = keypair.public_key

url = f"https://friendbot.stellar.org?addr={public_key}"
response = urllib.request.urlopen(url)
body = json.loads(response.read())
funding_tx = body.get("hash")

Boom. 10,000 XLM, instantly. Stellar was designed for developers.

Ethereum? Different story entirely.

account = Account.create()
address = account.address
private_key = account.key.hex()

Then you have to go find a faucet to fund the account on the testnet. I used the Ethereum Sepolia Faucet from Google, which let me get started quickly with 0.05 Sepolia ETH.

Ethereum's testnet requires you to prove you're human. This isn't a flaw—it's a design choice. Sepolia is scarce by design, and that scarcity forces you to be intentional about testing.

The Decimal Problem

I discovered the decimal limits problem by accident. I tried sending 10.12345678 XLM and Stellar rejected it silently at the RPC level. Turns out Stellar stops at 7 decimals:

value = Decimal(amount)
decimals = abs(value.as_tuple().exponent) # Stellar: max 7

Ethereum goes much further. 18 decimals, because wei (10^-18 ETH) is the smallest unit:

value = Decimal(amount)
amount_wei = Web3.to_wei(value, "ether") # Ethereum: max 18

This taught me something: blockchains aren't interchangeable. They have hard constraints baked into their protocol. You can't just move code from one to the other—you have to understand and respect each system's limits.

Memos: Different Tradeoffs

I wanted to include descriptive memos in transactions—something like "invoice #12345" to track payments. Stellar made this simple:

memo = "payment"
builder.add_text_memo(memo) # 28 UTF-8 bytes, free

28 bytes, included in the base fee. Done.

Ethereum doesn't have native memos. Instead, you encode data into the transaction itself:

memo = "payment"
data = "0x" + memo.encode("utf-8").hex()
tx["data"] = data # Each byte ≈ 16 gas

This is expensive. A 256-byte memo costs ~4,000 gas, which at current rates is about $0.10. So on Ethereum, you think twice before adding metadata to a transaction. On Stellar, you just throw it in. Different design, different tradeoffs.

The Destination Problem

When I tested sending to a non-existent account, I got completely different behaviors.

On Stellar, it fails:

server.load_account(destination) # Check if account exists

Stellar makes you prove the destination account is real. If it doesn't exist, you can't send. You need a create_account operation first, then send. Two steps. This forces you to think about what you're doing.

On Ethereum, any address is valid:

destination = Web3.to_checksum_address(destination) # Normalize with checksum

Ethereum will happily send funds to an address that doesn't exist yet. The address is valid, the transaction succeeds, and now those funds are locked in a contract address nobody owns. Forever. The checksum helps you avoid typos, but it can't save you from a wrong address.

Fee Models: The Biggest Difference

This is where Stellar and Ethereum revealed their fundamentally different philosophies.

Stellar's fee model is dead simple:

base_fee = server.fetch_base_fee()
ops_count = len(transaction.transaction.operations)
fee = base_fee * ops_count / 10_000_000

print(f"Fee: {fee} XLM ({ops_count} operations)")

Fetch the base fee, multiply by operation count, done. Fees are predictable. They scale gently with network load. You can estimate gas costs accurately before submitting.

Ethereum's model is... more complex:

gas_price = w3.eth.gas_price
tx = {"from": source, "to": dest, "value": amount_wei}
gas_limit = w3.eth.estimate_gas(tx)

fee = gas_limit * gas_price
print(f"Fee: ~{Web3.from_wei(fee, 'ether'):.8f} ETH")

You have to estimate. And the estimate can change. The ~ in my output isn't cosmetic—it's a warning that this fee is approximate. Gas prices spike. Estimates are wrong. You have to check again right before submitting. This forces a different workflow: always dry-run first.

Sequences and Nonces: The Sync Problem

Both blockchains use counters to ensure transactions don't get duplicated or reordered. But they surface the problem differently.

Stellar's sequence numbers auto-increment:

source_account = server.load_account(public_key)
builder = TransactionBuilder(source_account=source_account, ...)
transaction = builder.build()

Convenient, but also dangerous. If you submit two transactions concurrently, they'll have the same sequence number and one will fail. For scripts, I document it as a limitation. For production systems, you'd need a queue or mutex.

Ethereum's nonce is something you fetch fresh:

nonce = w3.eth.get_transaction_count(source_address)
tx = {"nonce": nonce, ...}

Same problem, same solution needed, but now it's explicit. You're responsible for fetching the nonce. You see the problem immediately. In a way, Ethereum forces you to think about concurrency.

Key Management: Two Philosophies

For testing, both blockchains let you use plain text private keys. But Ethereum also shipped with a production-ready key format: the encrypted keystore.

Stellar: plain text with restricted permissions:

with open(f"keys/{public_key}.secret", "w") as f:
os.chmod(f.name, 0o600) # chmod 600
f.write(secret_key)

For testnet, this is fine. For mainnet, you're on your own.

Ethereum: built-in encryption:

password = getpass.getpass("Choose password:")
keystore = Account.encrypt(private_key, password)

# Use it later
python send_eth.py --keystore keystores/0x123....json
# Prompts for password at runtime

So I built both workflows. For Ethereum mainnet, I always use encrypted keystores. The password is never stored—I type it on each use. It's an extra step, but it matters.

RPC Reliability: Stellar vs. Ethereum

Stellar has one official endpoint: Horizon. Rock solid.

Ethereum has hundreds of public RPC endpoints, and they're all flaky:

rpcs = ["https://ethereum-sepolia-rpc.publicnode.com", "https://rpc.sepolia.org", ...]

for rpc_url in rpcs:
w3 = Web3(Web3.HTTPProvider(rpc_url))
if w3.is_connected():
break

Public RPCs go down. They rate-limit. They're unreliable. So I retry multiple endpoints. It's annoying, but necessary.

Error Messages: Structured vs. Parsed

When a transaction fails, Stellar is explicit:

# Horizon returns structured codes like:
# "tx_insufficient_balance", "tx_bad_seq", "op_no_destination"

Read the code, understand what went wrong, fix it.

Ethereum returns error messages:

# RPC errors arrive as strings like:
# "insufficient funds", "nonce too low", "gas too low"

I had to write string parsing logic to extract the actual error. Not elegant, but it works.

Audit Logging: Non-Negotiable

After a successful transaction on either blockchain, I log it immediately:

timestamp = datetime.now(timezone.utc).isoformat()
log_line = f"{timestamp} | {network} | from={source} | to={dest} | amount={amount} | tx={tx_hash}\n"

with open(f"transactions.{network}.log", "a") as f:
f.write(log_line)

Never log private keys—just source, destination, amount, and tx hash. This is your receipt. Months later, you'll need proof of what happened. The log is that proof.

Dry-Run: Always Test First

Both scripts support a --dry-run flag that simulates the transaction without submitting:

if dry_run:
print(f"From : {source}")
print(f"To : {destination}")
print(f"Amount : {amount}")
print(f"Fee : {fee}")
print(f"Balance: {balance}")
return

On Ethereum especially, this is critical. You see the gas cost before committing. You verify the balance. You check the destination address. The workflow becomes: dry-run testnet, dry-run mainnet, submit mainnet.

The Real Lesson

I built these scripts because I don't trust black boxes with my crypto. Every exchange, every wallet, every dapp is a centralized point of failure. Any one of them could have a bug that drains your account. Any one could get hacked. Any one could disappear.

But when I read my own code—when I wrote every line—I know exactly what happens when I hit enter. No hidden endpoints. No intermediaries. Just Python + standard libraries + direct blockchain calls.

Stellar and Ethereum are solid platforms. But they can't protect you from:

  • A buggy wallet that miscalculates gas
  • An exchange that locks your account
  • A dapp that requests too many permissions
  • A private key stolen from a browser cache

Self-custody through auditable code is the answer.

If You're Building This Too

  1. Trust code, not UIs, You should understand every line that moves your assets.
  2. Start on testnet, Free funds. Zero risk. Perfect for learning.
  3. Log everything, You need proof. The log is your receipt.
  4. Dry-run first, See the fee. See the structure. Before you commit.
  5. Keep it simple, No abstractions. No magic. Just straightforward steps.

If you're not comfortable reading the code that moves your assets, don't use it. And if you're using a service you can't read the code for, you're trusting someone else's judgment with your money.

Read the code. Audit it. Modify it. Own it.

Source Code

Both scripts are open source: