Transcribed from handwritten notes — 2 pages

Multi-chain wallet:
primitives & interfaces

One idea runs through both pages: data is shared across chains, behaviour sits behind traits. Bitcoin and Ethereum only diverge at the builder, signer and broadcaster implementations — never in the types they hand to each other.

language: Rustchains: BTC · ETHlayers: 3traits: 6

Transaction lifecycle

The order the traits meet in, read off the right-hand column of both pages. Each stop is one trait.

01
Wallet
holds the address and the keypair
02
TxBuilder
inputs + destination → unsigned tx
03
Signer
signs; LocalSigner wraps a keypair
04
TxBroadcaster
submits, then waits for confirmations
05
Transactioner
indexer reads the chain back

Layer 1 — Primitives

Data types with no behaviour. Newtypes over String and int, so an address can never be passed where a transaction id belongs.

Address newtype

  • String

The notes start with separate BitcoinAddr and EthAddr, then collapse them into one type.

TransactionId newtype

  • String

NetworkId newtype

  • int (u32)

Chain struct

  • network_id: NetworkId
  • name: String

Mainnet = Chain { network_id: 0, name: "btc" }

Transaction struct

  • to: Address
  • amount: Decimal
  • id: TransactionId

from is struck out on the page — a UTXO transaction has no single sender.

Keypair struct

  • address: Address
  • key: String
  • fn address() -> Address
↓ ↓ ↓

Layer 2 — Traits

The middle and right columns of page one, plus the top of page two. The star next to sign() is in the original: it is the one method every builder must have.

Addresser

  • fn address() -> Address

Transactioner

  • fn transaction() -> Transaction

First written as TransactionUtxo, then renamed — the indexer normalises both chain shapes into the same Transaction.

Wallet

  • fn address() -> Address
  • fn keypair() -> Keypair

Signer

  • fn sign(...)
  • impl LocalSigner::new(keypair)

Local is the qualifier that matters — it leaves room for a remote or HSM signer behind the same trait.

TxBuilder

  • fn destination() -> Address
  • fn sign() ★
  • fn transaction_id() -> Option<TransactionId>
  • fn broadcast() -> Broadcaster

Generic over Utxo / Wallet. The Option is written in above a crossed-out hash() — there is no id until the tx is signed.

TxBroadcaster

  • fn broadcast(TransactionId)
  • fn wait(confirmations)

wait(0,1) in the notes — 0 for mempool acceptance, 1 for a first confirmation.

↓ ↓ ↓

Layer 3 — Per-chain implementations

The whole difference between the chains lives here: Bitcoin builds from a list of inputs, Ethereum from a single signer.

◆ Bitcoin — UTXO model

BtcTxBuilder

  • static new(inputs, destination)
  • fn sign()
  • fn broadcast()

Input struct

  • signer: Signer
  • index: u8

Held as an array. Each input is signed separately, which is why the signer belongs to the input rather than the builder.

Output / destination

  • output index: u8
  • destination: Address

A first pass listing output, destination and keypair as loose fields is struck out and replaced by the Input struct above.

BtcTxBroadcaster

  • static new(JsonRpc(...))
  • fn broadcast(TransactionId)

Transport is injected — the broadcaster takes a JSON-RPC client rather than building one.

BitcoinTx indexer

  • fn transaction() -> Transaction
  • fn output() -> int
◈ Ethereum — account model

EthTxBuilder

  • static new(signer, destination)
  • fn sign()

One signer for the whole transaction — no input array, and no broadcast() listed on the builder.

Builder inputs

  • signer: Signer
  • destination: Address

keypair is crossed out and replaced by signer — the same correction made on the Bitcoin side.

EthTx indexer

  • fn transaction() -> Transaction
  • fn from() -> Address

from() survives only here, after being dropped from the shared Transaction.

Rust skeleton

The notes written out so they would compile. Return types are filled in where the page left them implicit.

// ---- primitives ----
pub struct Address(String);
pub struct TransactionId(String);
pub struct NetworkId(u32);

pub struct Chain { network_id: NetworkId, name: String }

pub struct Transaction {
    to: Address,
    amount: Decimal,
    id: TransactionId,
}

pub struct Keypair { address: Address, key: String }

// ---- interfaces ----
pub trait Addresser     { fn address(&self) -> Address; }
pub trait Transactioner { fn transaction(&self) -> Transaction; }

pub trait Wallet {
    fn address(&self) -> Address;
    fn keypair(&self) -> Keypair;
}

pub trait Signer {
    fn sign(&self, payload: &[u8]) -> Signature;
}

pub trait TxBuilder {
    fn destination(&self) -> Address;
    fn sign(self) -> SignedTx;                        // ★ the core method
    fn transaction_id(&self) -> Option<TransactionId>;  // None until signed
}

pub trait TxBroadcaster {
    fn broadcast(&self, tx: SignedTx) -> TransactionId;
    fn wait(&self, id: &TransactionId, confirmations: u8);
}

// ---- implementations ----
pub struct BtcInput { signer: Box<dyn Signer>, index: u8 }

impl BtcTxBuilder {
    pub fn new(inputs: Vec<BtcInput>, destination: Address) -> Self;
}

impl EthTxBuilder {
    pub fn new(signer: Box<dyn Signer>, destination: Address) -> Self;
}

impl LocalSigner        { pub fn new(keypair: Keypair) -> Self; }
impl BtcTxBroadcaster  { pub fn new(rpc: JsonRpc) -> Self; }

Crossed out on the page

Decisions already made in ink. Kept visible so they don't get re-litigated.

DroppedReplaced byWhy it holds up
from: AddressA UTXO transaction has many senders; from() stays on EthTx only.
TransactionUtxoTransactionerA shared trait shouldn't be named after one chain's model.
keypairsignerBuilders never hold a private key, only something that can sign.
hash()transaction_id()Returns a domain type, and an Option — an unsigned tx has no id yet.
BitcoinAddr / EthAddrAddressOne type; validation happens in the chain layer.
btc (on TxBroadcaster)The trait was briefly chain-specific, then made generic.

Open questions

Where the notes stop, or where the handwriting doesn't resolve.