diff --git a/README.md b/README.md index 79fbb8f..0101d9e 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ All responses use JSON. Successful responses contain `success: true` and a | `GET` | `/api/v1/addresses/validate?address=...` | Validate a registered canonical address | | `GET` | `/api/v1/addresses/vanity/resolve?address=...` | Resolve a vanity address to its owner | | `GET` | `/api/v1/addresses/registration?address=...` | Check wallet registration | +| `POST` | `/api/v1/addresses/register` | Submit a locally signed wallet registration | | `GET` | `/api/v1/balances/base?coin=CLTC&address=...` | Return one base-coin balance | | `GET` | `/api/v1/balances?address=...` | Return every balance owned by an address | | `GET` | `/api/v1/transactions/lookup?txid=...` | Return a confirmed transaction | @@ -184,6 +185,27 @@ Transaction broadcasting accepts: The API never creates or signs a transaction for the caller. It only forwards the complete signed transaction through `Client::submitTransaction()`. +Wallet registration accepts: + +```json +{ + "address": "40-character-address.cltc", + "public_key": "1794-character-public-key-hex", + "signature": "1332-character-registration-signature-hex" +} +``` + +The authenticated address and public key must match the registration body. +The API forwards the registration through `Client::registerWallet()`; it never +creates the registration signature. + +Browser extensions must also be included in `API_CORS_ORIGINS` using their +exact origin: + +```text +API_CORS_ORIGINS=https://wallet.example.com,chrome-extension://extension-id +``` + ## Reliability Each RPC request still opens one connection, makes one request, receives one @@ -479,7 +501,7 @@ GET /api/v1/governance/proposals?proposal_key=... This returns the node's proposal, vote, implementation, and activation state as JSON. -The public API does not currently expose wallet-registration transaction -creation. A browser wallet must create and sign registration data locally -before an appropriate broadcast path can submit it. The API never registers a +The public API does not create wallet-registration signatures. A browser +wallet must create and sign registration data locally before submitting it +through `/api/v1/addresses/register`. The API never registers a wallet using credentials owned by the API operator. diff --git a/api.env.example b/api.env.example index 477ec62..e613f92 100644 --- a/api.env.example +++ b/api.env.example @@ -3,7 +3,7 @@ APP_ENV=production # Give every endpoint a unique name. Separate endpoints with commas. -CONTRACTLESS_RPC_NODES=node-1=contractless.dev:50050 +CONTRACTLESS_RPC_NODES=node-1=127.0.0.1:50050,node-2=192.0.2.10:50050 CONTRACTLESS_RPC_TIMEOUT=10 # An endpoint enters a temporary cooldown after this many consecutive diff --git a/public/index.php b/public/index.php index 1641a35..978b9f5 100644 --- a/public/index.php +++ b/public/index.php @@ -132,6 +132,62 @@ try { ), ]); }); + $router->post( + '/api/v1/addresses/register', + static function () use ($application, $requestCredentials): never { + $body = Request::json(); + $address = Request::canonicalAddress( + Request::bodyString($body, 'address', 45), + ); + $publicKeyHex = strtolower( + Request::bodyString($body, 'public_key', 1_794), + ); + $signature = Request::signature( + Request::bodyString($body, 'signature', 1_332), + ); + + if ( + strlen($publicKeyHex) !== 1_794 + || !ctype_xdigit($publicKeyHex) + ) { + throw new HttpException( + 422, + 'The public key must contain 1,794 hexadecimal characters.', + ); + } + if (!hash_equals($requestCredentials->address, $address)) { + throw new HttpException( + 403, + 'A wallet may only submit its own registration.', + ); + } + + $publicKey = hex2bin($publicKeyHex); + if ( + $publicKey === false + || !hash_equals( + $requestCredentials->handshakeProof->publicKey, + $publicKey, + ) + ) { + throw new HttpException( + 403, + 'The registration public key does not match the authenticated wallet.', + ); + } + + JsonResponse::success([ + 'address' => $address, + 'registered' => RpcReplyDecoder::registrationStatus( + $application->client->registerWallet( + $address, + $publicKey, + $signature, + ), + ), + ]); + }, + ); $router->get('/api/v1/balances/base', static function () use ($application): never { $address = Request::canonicalAddress(Request::queryString('address', 45)); $coin = strtoupper(Request::queryString('coin', 15)); diff --git a/src/Security/RateLimiter.php b/src/Security/RateLimiter.php index 38b57dc..475f7d6 100644 --- a/src/Security/RateLimiter.php +++ b/src/Security/RateLimiter.php @@ -26,7 +26,10 @@ final class RateLimiter 'limit' => $this->config->messageVerificationQuota, ]; } - if ($path === '/api/v1/transactions/broadcast') { + if ( + $path === '/api/v1/transactions/broadcast' + || $path === '/api/v1/addresses/register' + ) { $limits[] = [ 'scope' => 'broadcast', 'limit' => $this->config->broadcastQuota,