Ease is a multi-chain wallet, payments, contacts, and encrypted-chat product with passkey authentication, settling on an AML-gated stablecoin. Three services form the application, and two smart contracts hold the compliance and token layer.
The product combines capabilities that interlock:
A social layer — contacts, direct messages, group chats, presence, chat events.
A wallet and payment layer — multi-chain wallets, token transfers, payment requests, transaction history, blockchain event handling.
A custody layer — enclave-protected key creation, derivation, and transaction signing.
A compliance layer — on-chain KYC/AML status that gates stablecoin movement.
ease-api coordinates wallet business workflows and owns the product's state. ease-os is the authority for key and signing operations: key creation, derivation, and signing happen client-side directly against the enclave service, which verifies each WebAuthn ceremony itself. The API stores sealed key material and wallet metadata, and exposes the Credentials store back to ease-os over an API-key-secured proxy.
On chain, the amlregistry contract holds per-account approval status and the stabletoken contract reads it to gate transfers, mints, and redemptions of AML-enabled tokens.
Start here / System shape
System shape
Five network relationships carry the product. The one that surprises people is the third: the client talks to the enclave service directly, and the API never calls it.
Solid lines are outbound calls. Dashed lines are callbacks into Ease: BlockReporter posts chain activity to the API, and the enclave pulls JWKS and credential records from the API through the relay.
Encrypted chat messages, delivered and read events, transaction and presence fanout.
web app → OS relay
REST
Key creation and recovery, derivation, and transaction signing, each gated by a WebAuthn ceremony the enclave verifies itself.
OS → API
HTTP through the relay proxies
JWKS at /jwks/keys, and the Credentials store through the API-key-secured keys proxy.
BlockReporter → API
REST
Chain activity for watched addresses, authenticated with an API key.
The shared Credentials table
WebAuthn credential data and sealed wallet key material live in one DynamoDB table, Credentials, owned by ease-api and read by ease-os through the keys proxy. That is why the enclave can verify a passkey ceremony without the API mediating the signing request, and why the API can hold sealed material it has no ability to unseal.
Asynchronous spine
Three SQS FIFO queues carry work that must not block a request: tx, payments-events, and chat-events. Wallet creation, webhook events, payment detection, and chat delivery receipts all flow through them. Locally the same queues are emulated inside cmd/dev. See WebSocket and queues.
API reference / Route index
Route index
Every public route in one table. The generated OpenAPI reference at /docs carries the full request and response schemas.
Production
https://api.easesend.net
Staging
https://staging.api.easesend.net
Local
http://localhost:8080
OpenAPI
GET /docs, generated from operation metadata beside each handler
Not part of the client surface: GET /jwks/keys, POST /tx/webhook, the keys proxy consumed by ease-os, and the /_dev_ family. See Service routes.
API reference / Auth
Auth
Passwordless authentication: phone OTP or Google OAuth establishes identity, WebAuthn binds a device, and tokens are issued per device. Every token in the system carries a deviceId.
The staged flow
Authentication happens in two stages, and the token you hold between them is deliberately incomplete.
Phone or OAuth verification issues a pending access token plus a refresh token. A pending token is minted with userData = nil for a new device, so the device must complete passkey setup before it can act.
New accounts register a passkey and create wallet keys against ease-os; returning accounts complete a login challenge against existing credentials.
A complete access token and refresh token are issued.
Routes
Method
Path
Notes
POST
/auth/phone/send-otp
Rate-limited; backed by the PhoneNumbersRateLimits table with a TTL.
POST
/auth/phone/verify-otp
Creates the user account if needed. Issues a pending access token and a refresh token. Device-bound: requires X-Device-Id.
GET
/auth/oauth/google
Returns the Google authorization URL and sets cookies.
POST
/auth/oauth/google
Client sends code, state, and chainID with cookies. Issues a pending access token. Device-bound.
GET
/auth/oauth/google/callback
OAuth redirect target.
GET
/auth/join/lookup
Check whether an EASE account name is available.
POST
/auth/join/options
Requires the pending access token. Returns WebAuthn registration options and an X-Session-Id header.
POST
/auth/join/callback
Requires the access token and X-Session-Id. Carries the account name, optional recovery phrase, and recipientPublicKey. Returns valid tokens and the encrypted mnemonic. Device-bound.
POST
/auth/login/options
Returns WebAuthn authentication options and X-Session-Id.
POST
/auth/login/callback
Verifies the assertion against stored credentials. Returns valid tokens. Device-bound.
POST
/auth/refresh
Device-bound. Refresh tokens are whitelisted per device with a TTL.
POST
/auth/logout
Invalidates the refresh token.
Headers
Header
Used by
Meaning
Authorization
All protected routes
Bearer access token. Claims include userId and deviceId.
X-Device-Id
The five token-minting flows
Required wherever a token is minted for a specific device: verify-otp, login callback, OAuth exchange, join callback, refresh.
X-Session-Id
WebAuthn ceremonies
Returned by the options call, replayed on the callback. Backed by the ChallengeSessions table with a TTL.
Token types
Token
Carries
Notes
Access
deviceId, user data when complete
Pending variant has userData = nil until passkey setup completes.
Refresh
deviceId
Whitelisted in DynamoDB per device; the whitelist record also stores deviceId.
Tokens are JWT/JWX. Signing keys come from the LOCAL_JWT_* parameters locally and Secrets Manager in deployed environments, with active and retired key slots for rotation. The public set is served at GET /jwks/keys, which is what ease-os fetches to verify tokens inside the enclave.
What auth gates
Access to wallets, contacts, chats, payments, and signing operations. ease-os performs its own verification rather than trusting the caller's path, so a valid Ease token is required at both boundaries.
Registering a passkey on a new device invalidates credentials and refresh tokens on other devices, so recovery ends every other session.
Multi-chain wallets over BIP-44 derivation paths, across EASE, Bitcoin, and Ethereum. The API stores sealed key material and wallet metadata; it never derives or signs.
Routes
Method
Path
Notes
GET
/wallets
All wallets for the authenticated user, across chains.
GET
/wallets/{chain}
Wallets for one chain.
POST
/wallets/{chain}
Create a chain wallet. Registers the address with BlockReporter and enqueues an initial-history fetch.
GET
/wallets/{chain}/{address}
Wallet info including balances and metadata.
GET
/wallets/{chain}/{address}/txs
Transactions for the address.
GET
/chains
Supported chains and their tokens.
GET
/chains/{chain}
Chain metadata, fee info, supported tokens.
What wallet creation triggers
Creating a wallet is the entry point to the transaction history pipeline, not just a record write:
POST /wallets/{chain}
→ RegisterAddress(address) with BlockReporter
→ Enqueue TransactionEvent{chainID, address, blockHeight: 0} to the tx queue
A blockHeight of zero is the signal for a full historical fetch. The tx-consumer pulls the address's entire history from the chain API, stores it idempotently by txID, and sets TransactionHead to the latest block seen. Every later webhook event is an incremental fetch from that head forward. See Payment settlement.
Where the keys come from
Private key creation, derivation, and signing happen client-side against ease-os. The enclave derives extended public keys from the master key and returns them; the API associates the resulting addresses with the account and chain, and stores sealed key material in the shared Credentials table. See Account creation and recovery.
lib/chains holds the chain abstraction with EASE, BTC, and ETH implementations. lib/blockchainapi handles source APIs and transaction sync and caching. Provider keys are configured through LOCAL_INFURA_ETH_API_KEY and LOCAL_NOW_NODES_API_KEY locally; EASE account creation uses LOCAL_EOS_CREATOR_KEY and LOCAL_EOS_CREATOR_NAME.
API reference / Transactions
Transactions
The API builds unsigned transactions and broadcasts signed ones. What happens between those two calls is the part the source documents disagree about.
Routes
Method
Path
Notes
POST
/tx/{chain}
Create an unsigned transaction. Returns the unsigned payload and signing parameters.
POST
/tx/{chain}/sign/options
Start signing — WebAuthn challenge and X-Session-Id.
POST
/tx/{chain}/sign/callback
Complete signing with the assertion and encrypted payload.
POST
/tx/{chain}/broadcast
Broadcast a signed transaction. Accepts an optional paymentID.
GET
/tx/{chain}/{txid}
Transaction detail and current status.
GET
/tx/{chain}/{txid}/events
Chain events tied to the transaction.
Broadcast with a payment link
When paymentID is supplied, the broadcast handler validates before it submits anything to the chain:
Fetch the payment record and verify the authenticated user is the designated payer.
Reject if the payment is in any terminal state — expired, paid, or cancelled. The transaction is not submitted.
Reject if recipientAddress in the request body does not match payment.RecipientAddress.
Broadcast. On success, create a PaymentTransaction linking txID to paymentID, and log tx_linked.
Every broadcast carrying a paymentID is recorded, including partial amounts and overpayments. Creation uses attribute_not_exists(pk), so a duplicate broadcast for the same txID is rejected rather than double-linked.
Monitoring
Transaction state is event-driven, not polled. BlockReporter webhooks enqueue TransactionEvents; the tx-consumer fetches the block range from the stored TransactionHead to the reported height, stores transactions idempotently by txID, advances the head, and fans WebSocket events out to the user. Confirmation counts update as the same transaction reappears in successive fetches at higher heights.
Signing
See Transaction signing for the full sequence and the encryption applied to the payload before it reaches the enclave.
API reference / Payments
Payments
Payment requests are stored from each party's perspective, so payer and recipient each list their own view. Status only ever changes through the settlement pipeline.
Routes
Method
Path
Notes
POST
/payments/{chain}
Create a payment request. Stores the recipient's existing wallet address as the destination. Queues nothing.
GET
/payments/{chain}
List payments for a chain, from the caller's perspective.
GET
/payments/{chain}/{paymentId}
Payment detail.
GET
/payments/{chain}/{paymentId}/events
Chain events linked to the payment, such as deposits.
GET
/payments/logs
Immutable payment log entries.
Creation checks
The create handler verifies the invoice file is uploaded when one is referenced, the payer is a contact, and the wallet belongs to the user, then writes the payment record. Detection happens through the transaction pipeline, so creation queues no monitoring event.
Status model
Condition
Status
Linked transaction stored, below the confirmation threshold
unconfirmed
Linked transaction stored, threshold met, amount and recipient verified
paid
Past expiresAt when the payment event is processed
expired
Terminal states are expired, paid, and cancelled. A payment in any of them rejects further broadcasts against it.
Log events
Event
Trigger
tx_linked
A PaymentTransaction is created at broadcast.
funds_received
A linked transaction is stored by the tx-consumer — logged for every linked transaction regardless of amount or confirmation state.
unconfirmed
Linked transaction detected but below the confirmation threshold.
paid
Linked transaction confirmed, amount and recipient verified.
Logging funds_received unconditionally gives a complete audit trail of payment attempts, including partial payments that never reach paid.
Settlement
Every payment intended to settle a request must go through the API: the payer broadcasts through the normal transaction flow and supplies paymentID. There are no intermediary addresses, no automated sweeps, no refunds, and no payment updates outside this pipeline. The full sequence is on Payment settlement.
API reference / Chats and devices
Chats and devices
Room and membership management over HTTP, device and key-bundle distribution for encryption, and message delivery over WebSocket. The server routes ciphertext and never sees message bodies.
Chat routes
Method
Path
Notes
GET
/chats
Lists rooms; includes groupEpoch.
POST
/chats
Creates a DM or group room. Group rooms start at groupEpoch = 1; DM rooms use 0.
GET
/chats/events
Event readback. Queues delivered events for the messages it returns.
GET
/chats/{id}
Returns members and groupEpoch.
POST
/chats/{id}/leave
Increments groupEpoch.
POST
/chats/{id}/members
Increments groupEpoch.
PUT
/chats/{id}/members/{memberId}
Update member role.
DELETE
/chats/{id}/members/{memberId}
Increments groupEpoch.
Every membership change bumps the epoch, and sends carrying a stale epoch are rejected. That is the mechanism by which a removed member stops being able to send into the room with old sender-key state.
Device and key routes
Method
Path
Notes
POST
/devices
Registers or upserts a device owned by the caller.
GET
/devices
Lists the caller's devices.
POST
/devices/{deviceId}/bundle
Stores identityKey, one active signedPrekey, and a batch of oneTimePrekeys. The device must belong to the caller.
POST
/devices/{deviceId}/signed-prekeys
Rotates or uploads the signed prekey.
POST
/devices/{deviceId}/prekeys
Replenishes one-time prekeys.
GET
/devices/users/{userId}/bundles
Returns active recipient bundles for a target user.
Device registration
POST /devices
{
"deviceId": "device_a1",
"registrationId": 1234
}
Recipient bundle fetch
Access rules on GET /devices/users/{userId}/bundles:
If userId is not the caller, the caller must have a mutual contact relationship with the target.
Revoked devices are skipped.
Devices missing an identity key or an active signed prekey are skipped.
One one-time prekey is claimed server-side and returned when available.
One-time prekeys are consumed on fetch, so clients must replenish through POST /devices/{deviceId}/prekeys or new senders will fall back to bundles without a one-time key.
Message delivery
Messages do not go over HTTP. They are sent as chat:message frames on the WebSocket, one ciphertext per recipient device for DMs and one sender-key payload for groups. The full contract, validation rules, and delivery semantics are on Encrypted chat protocol.
Attachments
Attachments are upload IDs referenced from a message, with a maximum of ten per message, validated and scoped to the room. Attachment objects themselves remain unencrypted in S3 — a deliberate, documented gap.
API reference / Contacts
Contacts
Ease is a wallet organized around people. Contacts are the address book for chats, payment requests, and sends — and the access-control list for key bundle fetches.
Routes
Method
Path
Notes
GET
/contacts
List contacts with relationship and mutual status.
POST
/contacts
Add a contact.
GET
/contacts/search
Account search across users.
GET
/contacts/{id}
Get one contact.
DELETE
/contacts/{id}
Remove a contact.
GET
/contacts/{id}/wallets
The contact's wallet addresses, for addressing a send or a request.
Mutual status is load-bearing
Contact state is not a boolean. lib/database tracks relationships and mutual status, and two other subsystems depend on it:
Key bundles.GET /devices/users/{userId}/bundles requires a mutual contact relationship when the target is not the caller. Without it, a client cannot establish an encrypted session.
Payment requests. Creating a payment verifies the payer is a contact.
So removing a contact is not only a list operation — it withdraws the ability to start new encrypted sessions with that user and to name them as a payer.
Product intent
Make transactions feel like sending value to people rather than copying addresses.
Support search, requests, accepted contacts, and contact wallets.
Provide the social context that payment requests and messaging hang off.
API reference / Uploads
Uploads
Presigned S3 uploads with completion tracking in DynamoDB. Used for chat attachments and payment invoices.
Routes
Method
Path
Notes
POST
/uploads/presign
Creates the upload record and returns a presigned URL.
POST
/uploads/complete
Marks the upload complete and associates it with an entity.
GET
/uploads/{id}
Upload metadata. Access is checked before a download URL is issued.
Upload sequence
Client calls POST /uploads/presign; the API creates an upload record and returns the presigned URL.
Client uploads the file directly to S3.
Client calls POST /uploads/complete; the API marks the record complete.
The upload ID is then referenced — as a chat attachment, or verified as an invoice when a payment request is created.
Constraints
A chat message may carry at most ten upload IDs, validated and scoped to the room.
Payment creation verifies the referenced invoice file was actually uploaded.
Download URLs are presigned and issued only after an access check.
API reference / WebSocket and queues
WebSocket and queues
Real-time delivery runs on API Gateway WebSocket APIs with device-scoped connections. Asynchronous work runs on three SQS FIFO queues. Locally, both are emulated inside the dev server.
Getting a connection
Client requests a WebSocket token for a registered device: POST /ws/token?deviceId=device_a1.
ws-token verifies that (userId, deviceId) is a real device owned by the caller.
The token payload carries userId, deviceId, and chainId, and is short-lived.
Client connects with the token. ws-connect stores userId, deviceId, and connectionId, and updates the device's lastSeenAt.
Two lookup paths
Connections are indexed under both the user and the device, and delivery picks between them:
If a delivery target includes a deviceId, the dispatcher resolves connections for that exact user-device pair. This is how encrypted DM ciphertext reaches only the device it was encrypted for.
Otherwise it resolves every connection for the user. Presence, group messages, and delivered and read events use this path.
The dispatcher returns per-target results, so callers can distinguish live WebSocket delivery from storage-only delivery. Presence fanout remains user-level, not device-level.
Event kinds
chat:message carries the encrypted payload; delivered and read events remain server-visible metadata that reference room and message identifiers rather than content. GET /chats/events queues delivered events for the messages it returns, and the chat-events consumer broadcasts them to room members. Message payload shapes are on Encrypted chat protocol.
Queues
Queue
Carries
tx
TransactionEvent — initial wallet history fetch and incremental webhook-driven fetches.
payments-events
PaymentEvent — a stored transaction was linked to a payment request.
chat-events
Delivered and read events, and chat event fanout.
Production uses SQS FIFO. lib/queue.Client is an interface, so local mode substitutes a mock client. Each queue config sets a consumer, a retry count, and an optional batch size; the default batch size is ten, and the dev runtime batches per queue through a bounded worker pool.
Dev-only routes
Method
Path
Purpose
GET
/_dev_/ws
Browser WebSocket endpoint.
POST
/_dev_/ws/postToConnection
Local stand-in for the API Gateway Management API.
GET
/_dev_/ws/connections
Inspect live local connections.
POST
/_dev_/queues/addToQueue
Local queue ingress used by the mock queue client.
Handy for exercising consumers without waiting for a chain event: enqueue a TransactionEvent on the tx queue and the settlement pipeline runs end to end locally.
The /_dev_ family exists only in cmd/dev and is not part of the deployed API.
API reference / Service routes
Service routes
Four surfaces that no client calls, and that carry more risk than most of the public API.
JWKS
Path
GET /jwks/keys
Consumer
ease-os enclave, through the relay's JWKS proxy on port 5025
Local wiring
go run ./cmd/relay --dev --dev-jwks-url http://localhost:8080/jwks/keys
The enclave validates Ease access tokens itself rather than trusting the caller's path, so it needs the public key set. Signing keys rotate through active and retired slots — LOCAL_JWT_ACTIVE_KEY and LOCAL_JWT_RETIRED_KEY locally, with refresh-token equivalents. A rotation the enclave cannot follow breaks signing, not just login.
Keys proxy
handlers/keys exposes the shared Credentials store to ease-os over an API-key-secured proxy. That table holds WebAuthn credential data alongside sealed wallet key material, and it is what lets the enclave verify a passkey ceremony and unseal the right key without the API brokering the operation.
The shared secret is configured locally as LOCAL_KEYS_SECRET.
The handler validates the API key, then enqueues a TransactionEvent with the current blockHeight onto the tx queue. It does no chain work itself — the consumer does the range fetch.
Debug and docs
Method
Path
Notes
POST
/debug
Debug actions. Deliberately excluded from the OpenAPI documentation. Table-clearing is gated by LOCAL_DEBUG_CLEAR_TABLES_KEY.
GET
/docs
Generated OpenAPI reference, built by cmd/dev from an httpx/openapi router.
Smart contracts / Overview
Smart contracts
An AML-gated stablecoin for Antelope/EOS chains, in two contracts: a per-account KYC/AML registry, and an eosio.token derivative whose tokens can be created with an AML gate that reads that registry.
Token contract with an optional AML gate on transfers, mints, and redemptions, plus pausing, seizure, and audit logs.
How the gate works
The AML flag is fixed per symbol at creation. On a gated token, transfers, mints, and redemptions require the involved parties to be Approved in the registry, with one exception: transfers at or below a configurable per-transfer exemption threshold move without approval checks. The token issuer and the contract account are exempt from the approval requirement.
The token contract reads the registry's applications table directly as a cross-contract read, so approval status is always evaluated against live registry state rather than a cached copy.
Testnet deployments
Chain endpoint: https://ease-testnet.easesend.net
Account
Contract
Tokens
amlregistry1
amlregistry
Shared registry for the tokens below
stabletoken1
stabletoken
USDA (AML on), FREE (AML off)
amlissuer
stabletoken
AMLUSD (AML on) — demonstration deployment
Calling conventions
Every action below is documented by its data JSON, which is what you pass whether you use cleos, eosjs, or Wharfkit.
The call returns a transaction_id and the state changes listed under each action become visible in the tables. The testnet RPC is load-balanced across nodes that lag by a block or two, so poll reads that depend on a just-sent transaction.
Failure
The transaction is rejected and no state changes. The error surfaces as assertion failure with message: <message>, and the exact messages are listed per action. Gate errors are prefixed for machine matching:
Prefix
Meaning
AML:
Registry gate — the account is not in the required approval state.
PAUSED:
Transfer-lock — the account is paused in the token contract.
Registry status codes
Code
Status
Effect on a gated token
0
Pending
Gated transfers fail; exempt-threshold transfers still move.
1
Approved
Full participation.
2
Rejected
Gated transfers fail. The applicant can resubmit.
3
Suspended
Gated transfers fail, and the balance becomes eligible for seize.
Suspension and pausing are separate
Suspension is an AML-status action in the registry: it removes Approved status, so gated transfers to or from the account fail while small exempt transfers still move. Pausing is a token-contract transfer-lock: a paused account can neither send nor receive at all, regardless of AML status or the exemption threshold, and the lock is contract-wide rather than per symbol.
Smart contracts / amlregistry
amlregistry
One application row per account, with an approval lifecycle managed by the registry owner and any delegated approvers. The stablecoin contract reads this table to enforce its AML gate.
Testnet account
amlregistry1
Table
applications, scoped to the contract, keyed by account
Statuses
0 Pending, 1 Approved, 2 Rejected, 3 Suspended
Notifications
Decision actions notify the applicant account
submitapp
Submit or resubmit a KYC application. Authorized by the applicant, who pays the row's RAM.
Argument
Type
Notes
account
name
Applicant account.
bank_name
string
Required, non-empty.
account_identifier
string
Required. Market-specific key info — in Kenya, an account number plus bank or branch code.
documents
string[]
References to off-chain document storage, as URLs or hashes. May be empty.
Creates the row with status Pending. If a row already exists and is Pending or Rejected, it is overwritten back to Pending and the previous decision fields are cleared — this is the resubmission path after a rejection.
Errors
AML: bank_name is required
AML: account_identifier is required
AML: cannot resubmit an approved or suspended application
missing authority of <account>
approve
Approve a pending application. Authorized by the registry owner or a delegated approver; decided_by must sign.
Argument
Type
Notes
account
name
Applicant.
decided_by
name
The deciding account: the registry account itself, or one listed in approvers.
suspend moves the row to Suspended; unsuspend returns it to Approved. Both record the decider and time and notify the account. Suspension is also the precondition for seize on the token contract.
Errors
AML: only an approved account can be suspended
AML: account is not suspended
Plus the lookup and authorization errors above.
setapprover
Grant or revoke delegated approver rights. Registry owner only.
Argument
Type
Notes
approver
name
Must be an existing chain account.
enabled
bool
true grants, false revokes.
{ "approver": "complianceop", "enabled": true }
Errors
AML: approver account does not exist
AML: already an approver
AML: not an approver
missing authority of <registry>
eraseapp
Delete an application row entirely. Registry owner only; used for right-to-erasure requests and test resets.
{ "account": "amlbob" }
Errors
AML: no application for this account
Reading approval status
The token contract reads the applications table directly. Off-chain callers use get_table_rows; an account is approved if its row exists with status == 1.
An eosio.token derivative. Each token carries an AML flag fixed at creation; on gated tokens, transfers, mints, and redemptions consult the AML registry, with an exemption for small per-transfer amounts.
Testnet accounts
stabletoken1 (USDA, FREE), amlissuer (AMLUSD)
Transfer signature
Standard eosio.token, so wallets and explorers stay compatible
Memo limit
256 bytes on every action that takes one
Exempt parties
The token issuer and the contract account
setregistry
Point the contract at the AML registry. Contract owner only, and required before any AML-enabled token is created.
{ "registry": "amlregistry1" }
Errors
registry account does not exist
create
Define a token. Contract owner only. The AML flag is fixed per symbol at creation.
Argument
Type
Notes
issuer
name
Account allowed to mint. Also AML-exempt.
maximum_supply
asset
Precision is taken from this value.
aml_enabled
bool
true gates the token; false gives plain eosio.token behaviour.
AML registry not configured; call setregistry first
mint
Mint directly to a recipient. Authorized by the token's issuer, the contract owner, or an account holding the mint role. Tokens are never routed through the issuer's balance.
Argument
Type
Notes
to
name
Recipient. Must pass the AML gate when enabled — there is no threshold exemption for mints — and must not be paused.
If quantity is at or below the per-transfer exemption threshold — 5 whole tokens unless changed with setthreshold — neither party needs approval. The exemption is judged per transfer, never cumulatively.
Otherwise both sender and recipient must be Approved. The issuer and contract account are exempt.
Pause checks apply either way.
Errors
AML: sender account '<from>' is not approved in the AML registry
AML: recipient account '<to>' is not approved in the AML registry
PAUSED: sender account '<from>' is paused, PAUSED: recipient account '<to>' is paused
overdrawn balance, cannot transfer to self, to account does not exist
must transfer positive quantity, symbol precision mismatch
transfermul
Payment splitting: many recipients in one atomic call. All splits succeed or none do. Authorized by the sender.
Threshold semantics: each recipient is judged on their own split amount, but the sender is judged on the total moved in the call, so a large send cannot pass the gate by being broken into small pieces.
Errors
Everything transfer can return, plus splits must not be empty, too many splits (max 100), all splits must use the same symbol, split quantity must be positive, and split recipient account does not exist.
redeem
Atomic burn with an audit record. Authorized by the holder. The burn happens directly from the holder's balance in a single call; the issuer's balance is never touched. The fiat payout to the named bank account is an offline process outside the contract.
Argument
Type
Notes
from
name
Redeeming holder. Must pass the AML gate — always, with no threshold exemption — and must not be paused.
The holder's balance and total supply are reduced, an audit row is appended to redemptions, and both the holder and issuer are notified. The row is structured as two logical sub-entries — received_by_issuer and burned — so the audit trail reads as a redemption narrative.
AML: redeeming account '<from>' is not approved in the AML registry
PAUSED: redeeming account '<from>' is paused
overdrawn balance, no balance object found
bank_name is required, account_identifier is required
must redeem a positive quantity, symbol precision mismatch
setthreshold
Set the per-transfer AML exemption for a symbol. Authorized by the token's issuer or the contract owner. Amounts at or below the threshold transfer without approval checks; 0 disables the exemption entirely. Defaults to 5 whole tokens when never set.
{ "max_exempt": "2.0000 USDA" }
The symbol on max_exempt selects which token the threshold applies to.
Errors
token with symbol does not exist
threshold cannot be negative, symbol precision mismatch
missing required authority (issuer or contract owner)
seize
Compliance action moving a suspended account's funds to the issuer. Authorized by the token's issuer or the contract owner. The account must first be suspended in the AML registry; seize then moves its entire balance of the given symbol to the issuer and writes an audit row. It works even if the account is also paused, and is available only on AML-enabled tokens.
Argument
Type
Notes
account
name
Must be Suspended in the registry.
sym
symbol_code
Which token to seize.
memo
string
Up to 256 bytes; stored in the audit row as a case or court reference.
{ "account": "amlbob", "sym": "USDA", "memo": "compliance case #123" }
The full balance moves to the issuer and supply is unchanged. Both the account and the issuer are notified.
AML: account is not suspended; suspend it in the registry before seizing
seize is only available for AML-enabled tokens
no balance to seize
cannot seize from the issuer or the contract
missing required authority (issuer or contract owner)
pauseacct and unpauseacct
Transfer-lock, authorized by the contract owner or an account holding the pause role. A paused account can neither send nor receive — transfers, mints, or redemptions — regardless of AML status or the exemption threshold. The lock is contract-wide rather than per symbol.
{ "account": "amlbob" }
A row is added to or removed from the paused table, recording who paused the account and when, and the account is notified.
Errors
account is already paused, account is not paused
account does not exist
missing required authority (issuer, contract owner, or delegated role)
setrole
Grant or revoke delegated rights. Contract owner only. Setting both flags to false revokes the role and deletes the row.
account does not exist, account has no role to revoke
open and close
Balance-row RAM management, as in eosio.token. open(owner, symbol, ram_payer) pre-creates a zero balance row paid for by ram_payer, who authorizes the call. close(owner, symbol) deletes a zero-balance row and is authorized by the owner.
Approval status comes from the registry — see amlregistry for the query and response shape.
Build
Compilation runs in a container with Antelope CDT 4.1, so Docker is the only prerequisite.
./build.sh
WASM and ABI artifacts are written to build/.
Deploy and end-to-end test
scripts/e2e.mjs deploys both contracts to the Ease testnet and runs a 53-assertion suite covering every action on both contracts: the full AML lifecycle from submit through reject, resubmit, approve and suspend; the both-sides transfer gate; the exemption threshold including defaults, split-bypass prevention, and reconfiguration; delegated approver and pause roles; seizure; pausing; split transfers; redemption audit records; open and close; and an ungated AML=false control token.
npm install
node scripts/e2e.mjs # deploy + full suite
node scripts/e2e.mjs --deploy-only # just deploy
Demonstration scenario
scripts/scenario.mjs plays a single narrative on a dedicated deployment — amlissuer issuing AMLUSD — with four user accounts: two approved, one rejected, and one never registered. Across 36 steps it deploys, configures, onboards, mints, transfers gated and exempt and split amounts, pauses through a delegated role, suspends and seizes and unsuspends, redeems, and erases, logging the transaction id of every successful step so the whole exercise is auditable on chain.
node scripts/scenario.mjs
Protocols / Account creation and recovery
Account creation and recovery
Three entry paths — new user, returning user on a known device, returning user on a new device — and one decision tree that determines whether an EASE account gets created, linked, or refused.
New user registration
Identity verification. Either phone — POST /auth/phone/send-otp then POST /auth/phone/verify-otp — or Google — GET /auth/oauth/google for the authorization URL, then POST /auth/oauth/google with code, state, and chainID. Either way the server creates the user account if needed and returns a pending access token plus a refresh token.
POST /auth/join/options with the pending token returns WebAuthn registration options and an X-Session-Id.
The client generates WebAuthn credentials.
POST /auth/join/callback with the credentials, the token, X-Session-Id, the chosen EASE account name, and a recipientPublicKey.
A master key is created: the enclave generates a BIP39 mnemonic, derives the master key, seals it with KMS, and encrypts the mnemonic to the client's recipientPublicKey.
Public keys are derived and the EASE account is created; the sealed key and addresses are stored.
Valid access and refresh tokens plus the encrypted mnemonic are returned.
The mnemonic reaches the client encrypted under a key the client generated, so it is readable by the user's device and by nothing in between — including the API.
Returning user on a registered device
POST /auth/login/options returns WebAuthn authentication options and X-Session-Id.
The user authenticates with the stored passkey.
POST /auth/login/callback with the signed assertion and X-Session-Id. The credential is verified and valid tokens are returned.
No enclave interaction and no mnemonic — the sealed key already exists and stays where it is.
Returning user on a new device
Structurally identical to registration, with recovery material added. The client does the encryption work before the request leaves the browser:
Identity verification as above, producing a pending token.
POST /auth/join/options, then new WebAuthn credentials.
The client generates a session RSA keypair, encrypts the mnemonic and password with AES, and encrypts the AES key with the enclave's public key.
POST /auth/join/callback carries the encrypted payload and recipientPublicKey. The account name must match the user's existing account name.
The enclave decrypts the AES key, then the mnemonic and password, derives the master key, seals it with KMS, and re-encrypts the mnemonic to recipientPublicKey.
Derived public keys are checked against the EASE account's owner. On a match, the stored sealed key is overwritten.
Valid tokens and the encrypted mnemonic are returned.
This flow invalidates credentials and refresh tokens on the user's other devices, ending those sessions.
Account linking decision tree
Given an account name and an optional recovery phrase, the join handler resolves one of eleven outcomes:
User already linked?
Recovery phrase?
Account exists on chain?
Outcome
No
No
No
Create the account with a generated seed and link it.
No
No
Yes
Rejected — account already taken.
No
Yes
No
Create the account with the recovered seed and link it.
No
Yes
Yes, seed owns it
Link the user to the recovered account.
No
Yes
Yes, seed does not own it
Rejected — account already taken.
Yes, name mismatch
—
—
Rejected — account does not match.
Yes, name matches
No
No
Create the account with a generated seed and link it.
Yes, name matches
No
Yes
Rejected — a correct secret phrase is required.
Yes, name matches
Yes
No
Create the account with the recovered seed and link it.
Yes, name matches
Yes
Yes, seed owns it
Link the user to the account.
Yes, name matches
Yes
Yes, seed does not own it
Rejected — a correct secret phrase is required.
What the enclave does in these flows
Generate or recover the mnemonic, derive the master key, seal it under KMS, derive extended public keys, and encrypt the mnemonic to the client's public key. The client drives these calls directly against ease-os, which verifies the WebAuthn ceremony itself.
Protocols / Transaction signing
Transaction signing
A send involves three parties and two encryption layers: the API builds and broadcasts, the enclave signs, and the client encrypts the payload so that only the enclave can read it.
Sequence
The user enters transaction details. The client calls POST /tx/{chain} and receives an unsigned transaction plus signing parameters.
Passkey verification and signing happen directly between the client and ease-os, which verifies the WebAuthn ceremony itself and reads the sealed key material through the API-key-secured keys proxy.
The enclave unseals the key with KMS, signs inside the isolated environment, and returns the signed transaction.
The client calls POST /tx/{chain}/broadcast with the signed transaction, and optionally a paymentID and recipientAddress to settle a payment request.
The API broadcasts to the chain, records a PaymentTransaction when a payment was named, and returns the txID.
Confirmation state arrives asynchronously through BlockReporter and the tx-consumer.
Payload encryption
The client encrypts the unsigned transaction and signing parameters with AES.
The client encrypts the AES key with the enclave's public key, forming the recipient data.
Only the enclave can recover the AES key, so nothing in the transport path can read or alter the transaction being signed.
Inside the enclave, KMS releases the data key only under attestation conditions, so the sealed signing key is usable only from a matching enclave image.
After broadcast
The transaction enters the history pipeline immediately: BlockReporter observes activity on the watched address, the API enqueues a TransactionEvent, and the tx-consumer stores the transaction and advances the address's block head. If the broadcast carried a paymentID, the same pass publishes a PaymentEvent that drives the payment's status. See Payment settlement.
Protocols / Encrypted chat
Encrypted chat protocol
The backend treats chat as ciphertext-first. Direct messages are device-targeted, group messages are epoch-gated, auth is device-scoped, and plaintext message bodies no longer exist anywhere in the server contract.
Principles
The server stores ciphertext, not plaintext chat bodies.
Device identity is first-class for direct-message delivery.
Group membership stays server-visible and is enforced through room membership plus groupEpoch.
Attachments remain unencrypted in S3.
Backward compatibility with the old plaintext contract was broken by design. The direction follows securechat without copying its code or reproducing its full protocol surface.
Establishing a session
The client creates a device identifier and a libsignal registration ID, then calls POST /devices.
It uploads bundle material with POST /devices/{deviceId}/bundle and refreshes keys through the signed-prekey and prekey endpoints.
To message someone, it calls GET /devices/users/{userId}/bundles and receives each active device's identity key, active signed prekey, and a claimed one-time prekey when available.
One unified message shape, with kind deciding which branch is valid. The rules are strict and mixing the two branches is rejected.
Direct messages
Sender must be a room member; the room must not be a group room.
recipientDeviceMessages is required.
The payload must not include ciphertext, ciphertextType, or groupEpoch.
The room must resolve to exactly one recipient user.
A ciphertext must be present for every active recipient device — partial coverage is rejected.
Duplicate (recipientUserId, recipientDeviceId) pairs are rejected, as are ciphertexts for non-members or inactive devices.
Group messages
Sender must be a room member; the room must be a group room.
ciphertext and ciphertextType are required; recipientDeviceMessages must not be present.
groupEpoch must equal the room's current epoch.
Attachments
Upload IDs are validated and attached to the room scope; maximum of ten per message.
The full-coverage rule means a client must fetch fresh bundles before sending: a recipient who registered a new device since the last fetch will cause the send to fail rather than silently drop that device.
Delivery and storage semantics
This is where the implementation is more subtle than the model suggests.
Stream
What happens
Sender WebSocket
Receives a chat:message immediately with kind = "dm", ciphertextType = "dm_fanout", and an empty ciphertext. Attachments and metadata are included.
Sender storage
One event, containing the full recipientDeviceMessages[] fanout map rather than a single ciphertext.
Recipient WebSocket
One message per recipient device target; each live device receives only its own ciphertext.
Recipient storage
One event per recipient user, not per device, containing only that user's subset of recipientDeviceMessages[].
The storage model is per-user event streams with device ciphertext arrays embedded in DM events rather than a per-device inbox, so a client reading history selects its own device's ciphertext out of the stored array.
Group messages are simpler: the same ciphertext is written to every member's event stream and fanned out to every member's live connections. Group delivery is user-targeted at the broadcast layer rather than device-targeted in the payload.
Group epochs
Membership changes in the room.
The server increments GroupEpoch.
Sends carrying the old epoch are rejected.
Clients rotate sender-key state for the new epoch.
New group rooms start at epoch 1; non-group rooms sit at 0. Add-member, remove-member, and leave-group all increment. GET /chats and GET /chats/{id} expose the current value so clients can detect a rotation they missed.
What the server still sees
Room membership and device ownership.
Presence, and delivered and read metadata.
Attachment references — and the attachment objects themselves, unencrypted in S3.
The server does not attempt to derive plaintext from ciphertext. Media encryption was not implemented, and full securechat protocol parity is an explicit non-goal.
Verification in place
Focused tests cover: token claims include deviceId; the dispatcher uses device-targeted lookups; DM sends reject missing recipientDeviceMessages, mixed DM and group fields, and incomplete device coverage; group sends reject stale epochs; ciphertext is stored unchanged; bundle fetch enforces mutual-contact access; membership changes increment the epoch on add, remove, and leave; and history readback returns ciphertext unchanged.
Protocols / Payment settlement
Payment settlement pipeline
All transaction history — initial wallet fetch and live events alike — flows through one queue. Payments are only ever settled from transactions that were explicitly linked at broadcast and then observed on chain.
Design rules
All history flows through the tx-consumer queue regardless of source.
Payment status changes only when a transaction is explicitly linked to a payment request through the API and detected on chain by the tx-consumer.
The payments consumer validates from DynamoDB and makes no blockchain calls.
No intermediary addresses, no automated sweeps, no refunds.
No payment updates outside this pipeline.
Two entry points into the tx queue
Initial fetch
Wallet creation registers the address with BlockReporter and enqueues a TransactionEvent with blockHeight: 0. Zero means fetch everything: the consumer pulls the full history from the chain API, stores it, and sets TransactionHead to the latest block seen.
Live events
Each BlockReporter webhook enqueues a TransactionEvent with the current height. The consumer reads TransactionHead, fetches from that block to the reported one, stores, and advances the head. Every event is a bounded incremental fetch, never a re-scan. Confirmation counts on existing transactions update as the same transaction reappears at higher heights.
Linking a payment at broadcast
The payer uses the normal transaction flow and supplies paymentID at broadcast. The handler validates payer identity, non-terminal status, and recipient address match before submitting anything to the chain, then creates a PaymentTransaction and logs tx_linked. Details on Transactions.
tx-consumer
Map chainID to the internal chain name.
Read TransactionHead to determine the fetch range.
Store every fetched transaction — unconditional, idempotent by txID.
Advance TransactionHead.
For each stored transaction, look up PaymentTransaction by txID.
If found, log funds_received and publish a PaymentEvent to the payments queue.
The consumer stores, logs, and routes. It never updates payment status.
payments-events-consumer
Fetch the payment from DynamoDB; reject if terminal or expired.
Fetch the transaction from DynamoDB by txID — guaranteed present, because the tx-consumer stores before it routes.
Check tx.Confirmations against the per-chain minimum.
Update status and log the change.
No requeueing. Subsequent BlockReporter events naturally re-trigger the pipeline as confirmations accumulate on the stored record. That "store first, then route" ordering is what removes the network call from validation.
PaymentTransaction
Purpose
Links a blockchain txID to a payment request
Key
txID as partition key, no sort key — lookup is always a direct key read
chain is a plain attribute, verified during processing rather than used as a key. The 48-hour window covers the gap from broadcast to first detection — minutes for ETH and EASE, up to an hour for Bitcoin — with room for confirmation lag. The same TTL pattern is used by PhoneNumbersRateLimits, ChallengeSessions, and RefreshTokens.
Idempotency
PaymentTransaction creation uses attribute_not_exists(pk), so duplicate broadcast calls for the same transaction are rejected.
Transaction history writes are idempotent by txID.
Processing the same event twice stores the same records and republishes the same PaymentEvent; the payments consumer re-checks terminal status before writing.
Protocols / Data models
Data models
DynamoDB entities documented in the chat and payments designs. lib/database wraps DynamoDB with typed store interfaces and a generic table[T]; each entity implements tableKey() to describe its key shape.
type TableKey struct{ PK, SK string }
Store interfaces are defined near the entity logic, and handler modules embed only the storage interfaces they need. That is why a Lambda's main.go lists exactly the stores it touches.
Chat
ChatRooms
Property
Type
RoomID
string (pk)
IsGroup
bool
Title
string
GroupEpoch
int
CreatedAt
int
ChatUsers
Property
Type
UserID
string (pk)
RoomID
string (sk)
JoinedAt
int
ChatEvents
Property
Type
UserID
string (pk)
EventID
string (sk)
Type
string
Message.RoomID
string
Message.ActorUserID
string
Message.ActorDeviceID
string, optional
Message.MessageID
string
Message.Kind
string — dm or group
Message.GroupEpoch
int, optional
Message.CiphertextType
string
Message.Ciphertext
string
Message.RecipientDeviceMessages
array, optional
Message.Attachments
array, optional
CreatedAt
int
ExpiresAt
int, optional
The partition key is the user, not the room — event streams are per user, which is what makes fanout a write per recipient. The message DTO also carries referenceId for replies.
Devices and keys
Devices
Property
Type
UserID
string (pk)
DeviceID
string (sk suffix)
RegistrationID
int
CreatedAt
int
LastSeenAt
int
RevokedAt
int, optional
LastSeenAt is updated on WebSocket connect. RevokedAt is what causes a device to be skipped during bundle fetch.
DeviceKeys
Property
Type
DeviceID
string (pk)
identity
one record holding the device identity key
signed#{keyId}
signed prekey records
prekey#{keyId}
one-time prekey records
One-time prekeys are claimed server-side when a recipient bundle is fetched, so the record count falls with use and needs replenishing.
Payments and transactions
PaymentTransaction
txID (pk, no sort key), paymentID, chain, token, recipientUserID, payerUserID, createdAt, expiresAt. TTL 48 hours. Created at broadcast, deleted automatically once the detection window passes.
TransactionHead
Per-address marker of the last block processed. Read to determine the fetch range and advanced after each successful batch.
Shared and TTL tables
Table
Notes
Credentials
WebAuthn credential data plus sealed wallet key material. Owned by ease-api, read by ease-os through the keys proxy.
RefreshTokens
Refresh-token whitelist; records include deviceId. TTL.
userId, deviceId, connectionId; indexed for both user-wide and device-exact lookup.
lib/userdata handles KMS/HMAC-encrypted user data, so not every stored field is readable directly from a table scan.
Services / ease-api
ease-api
The business system of record: a Go backend on Lambda and API Gateway providing multi-chain wallets, payments, contacts, encrypted chat, and passkey authentication. Application code is organized around handler modules, with Lambda wrappers and a local dev server adapting platform events into handler inputs.
Production
https://api.easesend.net
Staging
https://staging.api.easesend.net
Local
http://localhost:8080, docs at /docs
Language
Go 1.25.4
Routing
github.com/eriicafes/httpx, with generated OpenAPI
Platform
Lambda custom runtime on Amazon Linux; API Gateway v2 HTTP and WebSocket
AWS
DynamoDB, S3, SQS FIFO, KMS, Secrets Manager, SSM
Auth libraries
go-webauthn for passkeys; JWT/JWX for access, refresh, and WebSocket tokens
Integrations
EASE/EOS, Bitcoin, Ethereum, Twilio, Google OAuth
Layout
ease-api/
├── app/ # AWS CDK app and local table/bootstrap scripts
├── cmd/dev/ # Local HTTP/WebSocket/queue dev server
├── handlers/ # HTTP, WebSocket, and queue handlers
├── lambdas/ # Lambda wrapper packages
└── lib/ # Shared domain, infrastructure, and integration packages
handlers
handlers is the application boundary. Module packages use *_handler package names and export a Handler type from handler.go. Each module owns its dependencies, with dependency groups first:
type Handler struct {
Secrets
Storage
// services used by this module
}
HTTP handlers are regular httpx handlers. Each REST action gets its own file, with the OpenAPI operation function directly above the handler method:
Handlers do not define muxes. Route assignment belongs to cmd/dev, the Lambda wrappers, or another runtime adapter — which is why the route list lives in router(ctx, m) rather than being scattered across handler files.
Secrets split
A handler module declares only the secrets its own methods use. Each Lambda wrapper declares its own Secrets struct above main, embedding the module's when one exists and adding whatever is needed to construct tokens, queues, WebSocket clients, storage clients, or external API clients. The result: the handler package shows the module's runtime requirements, and each main.go shows everything that Lambda needs.
Adapters
Platform event imports stay out of module handlers. Top-level adapters in handlers/adapter.go convert platform events into handler inputs: HTTPRequest, WebsocketProxyRequest, and SQSEvent. Shared handler types live in handlers/types.go, including queue event types and the local dev WebSocket and queue shapes.
lambdas
Lambda packages are production wrappers only: construct the module handler, adapt the event, call the method. Business logic belongs in handlers and lib. The long-term shape is one main.go per package with setup performed directly there.
cmd/dev
The local entrypoint loads .env, sets DEV_LOCAL=true, initializes logging, builds shared dependencies in setup, constructs every handler module, and registers all routes visibly. It runs one httpx mux, serves the /_dev_ helpers, and hosts the local WebSocket and queue runtimes so handlers stay independent of local mechanics.
Shared packages
Package
Responsibility
lib/auth
OAuth, phone OTP, passkey login against registered credentials, JWT types, issuer and verification helpers.
lib/blockchainapi
Source APIs, transaction sync and caching.
lib/blockreporter
HTTP client for the external blockchain monitor, including the internal chain ID reverse map.
lib/chains
Chain abstraction with EASE, BTC, and ETH implementations.
lib/database
DynamoDB stores and generic table helpers, including the Credentials table shared with ease-os.
lib/payments
Payment validation and lifecycle transitions.
lib/queue
SQS and local queue client, domain publishers.
lib/secrets
Secrets Manager and SSM loading with local fallbacks.
lib/storage
S3 presigned upload support.
lib/userdata
KMS/HMAC encrypted user data.
lib/websocket
API Gateway and local WebSocket clients.
lib/chatdelivery
Shared broadcast abstraction; resolves device-exact or user-wide targets and returns per-target results.
lib/utils
Logging, environment helpers, local HTTP client rewriting, request and response helpers.
Key creation, derivation, and signing happen client-side against ease-os. handlers/keys exposes the Credentials store to ease-os over an API-key-secured proxy.
Environment modes
Helper
True when
utils.IsLocal()
AWS_SAM_LOCAL=true or DEV_LOCAL=true
utils.IsDocker()
AWS_SAM_LOCAL=true or DOCKER=true
utils.IsStaging()
IS_STAGING=true
When the dev app is not running in Docker, utils.RewriteDockerInternalRequests() rewrites http.DefaultClient requests from host.docker.internal to localhost, preserving ports, so Docker-oriented fallback URLs work from a host go run. Local fallbacks load through lib/secrets from LOCAL_* variables; staging prefixes remote secret names with STAGING_.
Testing
go test ./...
cd app && npm test
Use focused runs for auth, encrypted user data, transaction signing, queue consumers, and payment validation.
Services / ease-os
ease-os
The cryptographic trust boundary. A relay on the parent EC2 instance forwards requests into an AWS Nitro Enclave that creates seeds, derives keys, and signs transactions — and verifies each WebAuthn ceremony itself rather than trusting a caller.
Client entry point
http://localhost:5050 locally; the relay endpoint in deployed environments
Called by
ease-web-app directly. ease-api does not call it.
Runtime
Go, AWS Nitro Enclaves
Transport
vsock in production; --dev substitutes localhost TCP
Auth
Ease access tokens verified in-enclave against JWKS, plus the WebAuthn ceremony
Reads
The Credentials table through the API's keys proxy
Operations
Operation
What it does
Attestation
Returns a Nitro attestation document proving which enclave image is running.
Enclave public key
The key clients use to encrypt setup material and AES keys for the enclave.
Seed creation
Generates a BIP39 mnemonic and BIP32 master key inside the enclave.
Seed recovery
Recovers a master key from a client-supplied mnemonic, encrypted to the enclave public key.
Sealing
Seals the master key with a KMS-managed data key. Only sealed material leaves.
Key derivation
Derives extended public keys for BIP-44 chain paths.
Transaction signing
Decrypts the AES-wrapped payload and signs inside the enclave.
cmd/relay/ holds the HTTP surface; enclave/ holds key handling, NSM, signing, and token verification.
Topology and ports
Port
Direction
Purpose
5050
client → relay
HTTP entry point for all enclave operations.
5005
relay → enclave
Enclave HTTP server.
5015
enclave → relay
KMS proxy.
5025
enclave → relay
JWKS proxy — reaches GET /jwks/keys on the API.
5035
enclave → relay
Structured log proxy.
The enclave has no network of its own; everything it needs is proxied by the relay over vsock. Start the relay first in local development — it opens the ports before the enclave connects back.
Running it
cd ease-os
go test ./...
go run ./cmd/relay --dev --dev-jwks-url http://localhost:8080/jwks/keys
CGO_ENABLED=0 go run ./cmd/enclave -dev
Under Compose the relay runs with -dev -keys-api-url http://ease-api:8080 and the enclave container shares the relay's network namespace. The enclave image takes WebAuthn relying-party build arguments, since it verifies ceremonies itself:
RP_ID: localhost
RP_ORIGINS: http://localhost:3000
RP_DISPLAY_NAME: Ease Local
The relying-party origin is compiled into the enclave image, so a change to the web app's origin requires an enclave rebuild and a matching KMS policy update.
KMS enforces attestation conditions before releasing data keys, and the binding is by PCR value:
run_enclave.sh rebuilds and restarts the enclave image.
PCR values change when the image changes.
update-enclave-policy.sh updates the KMS policy to match.
Sequence image changes and policy updates together, and run deployment scripts, policy updates, and Nitro or EC2 mutation commands only when they are intended.
Services / ease-web-app
ease-web-app client
The customer-facing application, and the only component that talks to both backends. It runs client-side crypto, holds the encrypted chat experience, and persists server and local state for responsive, offline-capable workflows.
Dev server
http://localhost:3000
Stack
React 19, TypeScript, Vite, Tailwind CSS 4
Data
TanStack Router, Query, React DB; Dexie for local persistence
Validation
Zod
Tests
Vitest
UI
Base UI / shadcn-style components
CI
Node 24, pnpm 11 — the README still says Node 18+
Configuration
Three environment variables define every outbound connection the client makes. Local values:
The split between VITE_ENDPOINT and VITE_OS_ENDPOINT is the trust boundary expressed in configuration. Requests that carry key material or transaction payloads for signing go to the second one.
Source layout
Path
Contents
src/routes/
TanStack file-based routes.
src/components/
Shared UI components.
src/lib/api/
API clients and query configuration. The de facto client-side API contract.
src/lib/collections/
Local DB schemas.
src/lib/websocket.ts
WebSocket connection management.
src/lib/sync.ts
Real-time sync engine.
src/utils/
Crypto, request, storage, formatting, and wallet helpers.
src/routeTree.gen.ts
Generated router file. Never hand-edit.
Responsibilities
Presenting authenticated and public routes.
Managing wallet, chat, contact, payment, and transaction workflows.
Calling ease-api REST and WebSocket endpoints.
Calling the ease-os relay for enclave-backed key and signing operations.
Persisting state through TanStack Query, TanStack React DB, and Dexie.
Running client-side crypto and encrypted messaging utilities.
Commands
cd ease-web-app
pnpm install
pnpm dev
pnpm build
pnpm test
pnpm check
pnpm format:fix
UI conventions
Keep changes consistent with the existing app experience: practical, dense enough for repeated wallet and chat workflows, and not landing-page-like. Use the existing components, hooks, API helpers, and Dexie/TanStack DB patterns before adding new abstractions. Let the repository formatter organize imports and Tailwind classes.
Services / address-block-reporter
address-block-reporter
A Go blockchain listener that watches registered wallet addresses and calls the Ease transaction webhook when they see chain activity. It sits outside the three core repositories but the wallet flows do not work without it.
Local endpoint
http://localhost:8090
Health
GET /health
Calls
POST http://ease-api:8080/tx/webhook in Compose
Local state
Mounted at ./dev/ease-block-reporter
Repository status
Contextual reference; uses live deployment
Role in the wallet path
ease-api registers a wallet address with the reporter. In Compose it reaches it at LOCAL_BLOCK_REPORTER_URL=http://ease-block-reporter:8090.
The reporter watches the chain for activity on that address.
On activity, it posts to the API transaction webhook.
The API updates transaction state and fans the event out over WebSocket.
Without it running, local wallet address registration and transaction webhook events do not work, so incoming-funds and confirmation flows cannot be exercised end to end.
This repository uses live deployment. Treat it as a reference project. Do not modify it unless explicitly asked.
Other sibling projects
Project
Status
securechat
Reference implementation of the end-to-end encrypted chat layer, not tied to Ease server or auth. Out of core scope unless explicitly requested.
ease-node-api
Utility Node APIs, including the live avatars Lambda endpoint.
ease-ios-app
Incomplete native SwiftUI Ease app.
Operating the stack / Local development
Local development
Three workflows: Docker Compose for the whole backend, a host go run ./cmd/dev loop for fast iteration, and SAM for exercising the deployed Lambda shape. All three still need ease-os running.
Docker stack
Clone ease-api, ease-os, and address-block-reporter as siblings beside the workspace root, then:
docker compose up --build
docker compose down
Starts DynamoDB Local with data under dev/dynamodb; a one-shot table bootstrap that waits for DynamoDB and runs ease-api/app/scripts/create_tables.sh; ease-api/cmd/dev, gated on that job; the ease-os relay, gated on the API being healthy; the enclave, sharing the relay's network namespace; and address-block-reporter, which wallet registration and webhook events depend on.
The web app is not in the Compose stack — run pnpm dev separately.
Host cmd/dev
Four terminals. Relay and enclave first, then storage, then the server:
cd ../ease-os
go run ./cmd/relay --dev --dev-jwks-url http://localhost:8080/jwks/keys
cd ../ease-os
CGO_ENABLED=0 go run ./cmd/enclave -dev
docker run --rm -p 8111:8000 amazon/dynamodb-local
cd app && ./scripts/create_tables.sh
cd ease-api
go run ./cmd/dev
cmd/dev handles local WebSocket and queue processing in-process, so consumers run without SQS.
Endpoints
Service
URL
REST API
http://localhost:8080
WebSocket
ws://localhost:8080/_dev_/ws
Queue ingress
http://localhost:8080/_dev_/queues/addToQueue
OpenAPI docs
http://localhost:8080/docs
OS relay
http://localhost:5050
Block reporter
http://localhost:8090
DynamoDB Local
http://localhost:8111
Web app
http://localhost:3000
DynamoDB Local listens on 8000 inside the network and is published on 8111. From Docker or SAM containers the endpoint is http://host.docker.internal:8111; region is us-east-1 everywhere.
SAM
Runs the Lambda wrappers in a deployed-like shape, separate from the cmd/dev stack. Relay, enclave, DynamoDB Local, and tables as above, then:
cd app
./scripts/build.sh
npx cdk synth --no-staging
cd ease-api
sam local start-api -t app/cdk.out/AppStack.template.json --region us-east-1 --env-vars env.json --port 8080 --skip-pull-image
Rebuild with ./scripts/build.sh after Lambda code changes; add npx cdk synth --no-staging when infrastructure changes.
Configuration
cmd/dev loads .env through godotenv. A missing .env is allowed and no separate shell export step is needed for normal local variables. Compose additionally expects ease-api/cmd/dev/.env, ease-os/.env, and address-block-reporter/.env, and sets fixed local AWS values itself.
Local secret fallbacks come from LOCAL_* variables. The names — not the values — are in env.json.example:
cd ease-api && go test ./...
cd ease-api/app && npm run build && npm run test
cd ease-web-app && pnpm check && pnpm test && pnpm build
cd ease-os && go test ./...
Use the smallest relevant set. If a command is skipped because it needs AWS, Docker, Nitro Enclaves, SAM, credentials, or live infrastructure, say so rather than leaving the gap implicit.
Local state
Runtime state lives under dev/: DynamoDB data, block reporter data, and Go build and module caches. Nothing under dev/ is committed.
Operating the stack / Environments and deployment
Environments and deployment
Production and staging are Terraform-managed and live outside these repositories. The CDK app inside ease-api only serves local emulation. Confusing the two is the most consequential mistake available here.
Base URLs
Environment
API
Branch
Production
https://api.easesend.net
main
Staging
https://staging.api.easesend.net
staging
Local
http://localhost:8080
—
Pipeline
GitHub Actions deploys changed Lambda directories on main and staging.
Lambda names and S3 deployment keys differ by branch.
Deployment is per-directory, so a change confined to one Lambda deploys only that Lambda.
Infrastructure ownership
CDK is local-only. The app in ease-api/app synthesizes templates for SAM emulation and local DynamoDB bootstrap. It does not describe or control deployed infrastructure, and cdk synth or cdk diff output is not a preview of what is live. Avoid manual cdk deploy, cdk destroy, and AWS mutation commands unless explicitly asked.
Production and staging infrastructure is provisioned by Terraform maintained outside these repositories. Anything about deployed topology, scaling, or networking has to be answered there.
Enclave deployment
Production uses vsock; --dev substitutes localhost TCP.
run_enclave.sh rebuilds and restarts the enclave image.
PCR values change when the image changes; update-enclave-policy.sh updates the KMS policy to match.
Do not run deployment scripts, policy updates, or Nitro/EC2 mutation commands unless explicitly asked.
Git layout
Each Ease app is its own git repository. Run git commands from the relevant app directory, not from the workspace root. The root workspace exists for local orchestration only.
Operating the stack / Security and secrets
Security and secrets
How to report a vulnerability, and the handling rules for credentials across the repositories.
Reporting a vulnerability
Contact
security@easesend.net
Include
Description, steps to reproduce, potential impact
Supported
The code deployed on main and staging
Report privately rather than opening a public issue or pull request, and allow reasonable time for triage before public disclosure.
Credential handling
Never print, copy, or commit: .env and env.json files, AWS credentials, KMS IDs, JWT and private keys, mnemonics, sealed keys and encrypted data keys, WebAuthn and passkey material, OAuth secrets, or Twilio secrets.
Use example files and documented variable names when explaining setup. Every configuration value in these docs comes from env.json.example, the Compose file, or the project READMEs — parameter names only.
Change bar for sensitive work
Changes involving encryption, authentication, attestation, transaction signing, queue consumers, or webhook processing carry focused tests. Use targeted test runs for auth, encrypted user data, transaction signing, queue consumers, and payment validation.
Generated artifacts
Take care with Lambda bootstrap binaries, zip files, CDK output, web dist, and local caches: they are easy to commit by accident and can carry embedded configuration.
Operating the stack / Contribution conventions
Contribution conventions
Rules that keep changes inside the boundaries the architecture depends on.
Scope
Work in ease-api, ease-web-app, and ease-os unless told otherwise. securechat, ease-node-api, ease-ios-app, and address-block-reporter may be explored as contextual references but not modified unless explicitly asked. Do not wander into unmentioned sibling projects.
Code
Prefer existing local patterns over new framework choices.
Keep Lambda changes scoped to the route or worker and the shared lib package involved.
Run gofmt on touched Go files.
Let the repository formatter organize TypeScript imports and Tailwind classes.
Do not hand-edit generated route trees or build output, including src/routeTree.gen.ts.
API changes
When adding API behaviour, update the web client types and helpers alongside backend validation.
When changing request or response schemas, consider offline sync, local DB persistence, and WebSocket event compatibility together.
The second rule is the one most often missed. The client is not a thin caller — it stores server state locally, so a schema change can break clients that have not reloaded, in ways that only appear on reconnect.
UI
Keep changes consistent with the existing app: practical, dense enough for repeated wallet and chat workflows, not landing-page-like. Reuse existing components, hooks, API helpers, and Dexie/TanStack DB patterns before adding abstractions.
Reference / Trust boundaries
Trust boundaries
Five boundaries define what each part of the system is trusted to do. Interface design decisions are mostly decisions about which side of a boundary a piece of data lands on.
Boundary
Trusted with
Not trusted with
Browser / client
User interaction, local state, client-side crypto, encrypting payloads for the enclave.
Anything that survives a compromised device without further protection.
Business backend
Identity, social graph, records, events, orchestration.
Plaintext master keys or signing authority.
Cryptographic
Seed, key, derivation, and signing operations inside the enclave.
Business logic and product state.
AWS / KMS
Releasing data keys only when attestation conditions are met.
Nothing sealed is usable without a matching enclave measurement.
Blockchain
Broadcast, webhooks, and transaction history.
Sits outside Ease's direct control; treat as untrusted input.
The governing principle
ease-api should coordinate wallet business workflows, but ease-os should remain the authority for sensitive key and signing operations. Coordination and authority are different things, and the split is what makes the custody story credible.
Value by component
Component
Customer value
Business value
ease-web-app
One place to chat, manage contacts, request payments, and send tokens.
Owns the user experience and retention surface.
ease-api
Reliable account, social, wallet, payment, and transaction services.
Owns business state and operational workflows.
ease-os
Higher-trust wallet creation and transaction signing.
Creates a security moat around custody and reduces backend risk.
Reference / Repository reference
Repository reference
Every project in the workspace, what it is for, and whether it is in scope.
Core
Repository
Stack
Role
ease-api
Go 1.25.4, AWS Lambda, AWS SDK v2, DynamoDB, S3, SQS, KMS, Secrets Manager, API Gateway, CDK (TypeScript), SAM
Go, AWS Nitro Enclaves, KMS/JWKS/log proxies over vsock
Cryptographic trust boundary.
Reference projects
Repository
Purpose
Modify?
securechat
Reference implementation of the E2E encrypted chat layer, not tied to Ease server or auth.
Only if asked
ease-node-api
Utility Node APIs, including the live avatars Lambda endpoint.
Only if asked
ease-ios-app
Incomplete native SwiftUI Ease app.
Only if asked
address-block-reporter
Go blockchain listener calling the API transaction webhook. Uses live deployment.
Only if asked
Command index
# backend
cd ease-api && go test ./... && go mod tidy
cd ease-api/app && npm run build && npm run test && npx cdk synth
# client
cd ease-web-app && pnpm install && pnpm dev
cd ease-web-app && pnpm check && pnpm test && pnpm build && pnpm format:fix
# enclave service
cd ease-os && go test ./...
cd ease-os && go run ./cmd/relay --dev
cd ease-os && CGO_ENABLED=0 go run ./cmd/enclave -dev
# whole backend stack
docker compose up --build
Reference / Ports and configuration
Ports and configuration
Every port, path prefix, and configuration name gathered in one place.
Client-facing ports
Port
Service
Notes
3000
ease-web-app
Also the local WebAuthn origin baked into the enclave image.