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:
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.
e5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e#Generation
Random key pair -- used for new accounts:
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:
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:
-
Canonical encoding -- the block is serialized into its canonical binary form via
MarshalBlockCanonical(). This is a fixed-layout binary encoding that excludesPoWNonce,Hash, andSignature. See Encoding for the full binary layout. -
Hash -- SHA-256 of the canonical bytes, hex-encoded:
go hash, err := core.HashBlock(b)// hash = hex(sha256(canonicalBytes)) -
Sign -- ed25519 signature over the raw hash bytes (not the hex string):
go err := core.SignBlock(b, kp)// Sets b.Hash and b.SignatureThe signature is computed as
ed25519.Sign(privateKey, hashBytes)wherehashBytesis the 32-byte SHA-256 digest (decoded from hex). -
Result --
b.Hashcontains the 64-character hex SHA-256 hash;b.Signaturecontains the 128-character hex ed25519 signature.
#Verification
Any node can verify a block without knowing the private key:
err := core.VerifyBlock(b)This performs three checks:
- Recompute hash -- re-encode the block canonically, compute SHA-256, and compare to
b.Hash. - Decode public key -- hex-decode
b.Accountinto a 32-byte ed25519 public key. - 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:
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
| Operation | Requirement |
|---|---|
send, lease, multisig_open, multisig_update | M-of-N (threshold signatures) |
receive, claim | 1-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:
{ "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.