Accounts & Keys

Identity, wallet creation, PoW challenges

Every XE account is an ed25519 key pair. The public key, hex-encoded, serves as the account address. There are no separate address formats, prefixes, or checksums -- the address is the public key.

#Key pair

The KeyPair struct holds both halves:

go
type KeyPair struct {    Public  ed25519.PublicKey  // 32 bytes    Private ed25519.PrivateKey // 64 bytes (seed + public key)}

#Account address

The account address is the hex-encoded public key: 64 hexadecimal characters representing 32 bytes.

text
e5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e

#Generation

Random key pair -- used for new accounts:

go
kp, err := core.GenerateKeyPair()// kp.Public  = 32-byte ed25519 public key// kp.Private = 64-byte ed25519 private key// kp.PubKeyHex() = "e5f6a7b8..." (64 hex chars)

Internally this calls crypto/ed25519.GenerateKey(crypto/rand.Reader).

Deterministic from seed -- recreates a key pair from a known 32-byte seed:

go
seed := make([]byte, 32)// ... fill seed from backup, derivation, etc.kp := core.KeyPairFromSeed(seed)

This calls ed25519.NewKeyFromSeed(seed), producing the same key pair every time for the same seed. The node persists its seed in {dataDir}/node.key so the account survives restarts.

#Signing

Blocks are signed with the account's private key. The process:

  1. Canonical encoding -- the block is serialized into its canonical binary form via MarshalBlockCanonical(). This is a fixed-layout binary encoding that excludes PoWNonce, Hash, and Signature. See Encoding for the full binary layout.

  2. Hash -- SHA-256 of the canonical bytes, hex-encoded:

    go
    hash, err := core.HashBlock(b)// hash = hex(sha256(canonicalBytes))
  3. Sign -- ed25519 signature over the raw hash bytes (not the hex string):

    go
    err := core.SignBlock(b, kp)// Sets b.Hash and b.Signature

    The signature is computed as ed25519.Sign(privateKey, hashBytes) where hashBytes is the 32-byte SHA-256 digest (decoded from hex).

  4. Result -- b.Hash contains the 64-character hex SHA-256 hash; b.Signature contains the 128-character hex ed25519 signature.

#Verification

Any node can verify a block without knowing the private key:

go
err := core.VerifyBlock(b)

This performs three checks:

  1. Recompute hash -- re-encode the block canonically, compute SHA-256, and compare to b.Hash.
  2. Decode public key -- hex-decode b.Account into a 32-byte ed25519 public key.
  3. Verify signature -- call ed25519.Verify(pubKey, hashBytes, sigBytes) to confirm the signature was produced by the account's private key.

If any step fails, the block is rejected.

#Account lifecycle

An account does not need to be "created" before it can receive funds. The first block on an account's chain opens the account. This can be:

  • A claim block -- claims 100 XUSD from the faucet.
  • A receive block -- accepts a pending send from another account.
  • A multisig_open block -- opens a multisig account with a hash-derived address.

Before the first block, the account has no chain, no balance, and no frontier. The account address (public key) exists as a mathematical entity -- it becomes active on the ledger only when its first block is added.

#Multisig accounts

A multisig account is controlled by M-of-N keyholders instead of a single key. The account address is a hash-derived address — the SHA-256 hash of the canonical keyset encoding:

text
address = sha256(threshold_u32_be || num_keys_u32_be || key1_32_bytes || key2_32_bytes || ...)

Keys are sorted lexicographically before encoding, so the same set of keys always produces the same address regardless of input order.

Multisig accounts are opened with a multisig_open block that registers the keyset on the ledger. The keyset can be rotated via multisig_update blocks (signed by the old keyset's threshold). The address never changes — it was derived from the original keyset.

#Signature requirements

OperationRequirement
send, lease, multisig_open, multisig_updateM-of-N (threshold signatures)
receive, claim1-of-N (any single keyholder)

Spending operations require full threshold. Additive operations (receiving funds) require only one signature, making multisig accounts operationally practical for incoming transfers.

Multisig blocks use a signatures array instead of the single signature field:

json
{  "signatures": [    {"public_key": "<pubkey1>", "signature": "<sig1>"},    {"public_key": "<pubkey3>", "signature": "<sig3>"}  ]}

#Delegation

Each block includes an optional Representative field -- a 64-character hex public key of the account that should vote on this account's behalf. This is how consensus weight is delegated. Only XUSD balances contribute to delegation weight; XE balances do not.

If Representative is empty on a block, the existing delegation is preserved — it means "keep current delegation", not "clear delegation". On the first block, an empty representative means no delegation is set.

See Delegation for details on how representative voting works.

#Key storage

The node stores its 32-byte seed in {dataDir}/node.key as raw bytes. The key pair is derived from this seed on startup. If the file does not exist, a new random seed is generated.