Browse Source
1. Moves the JMWalletDaemon service class into the jmclient package (see the wallet_rpc.py module). 2. Adds dependencies "klein" and "autobahn" to the jmclient package, as well as "pyjwt". 3. Adds another module websocketserver.py, using autobahn, to allow the JMWalletDaemon service to serve subscriptions over a websocket, for e.g. transaction notifications. 4. Adds tests both for the websocket connection and for the JSON-RPC HTTP connection. JmwalletdWebSocketServerFactory.sendTxNotification sends the json-ified transaction details using jmbitcoin.human_readable_transaction (as is currently used in our CLI), along with the txid. Also adds a coinjoin state update event sent via the websocket (switch from taker/maker/none). Require authentication to connect to websocket. 5. Add OpenApi definition of API in yaml; also auto-create human-readable API docs in markdown. 6. Add fidelity bond function to API 7. Add config read/write route to API 8. Remove snicker rpc calls temporarily 9. Updates to docoinjoin: corrects taker_finished for this custom case, does not shut down at end. 10. Address detailed review comments of @PulpCattel.master
19 changed files with 3028 additions and 734 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,580 @@
|
||||
# Joinmarket wallet API |
||||
Joinmarket wallet API |
||||
|
||||
## Version: 1 |
||||
|
||||
### /wallet/create |
||||
|
||||
#### POST |
||||
##### Summary |
||||
|
||||
create a new wallet |
||||
|
||||
##### Description |
||||
|
||||
Give a filename (.jmdat must be included) and a password, create the wallet and get back the seedphrase for the newly persisted wallet file. The wallettype variable must be one of "sw" - segwit native, "sw-legacy" - segwit legacy or "sw-fb" - segwit native with fidelity bonds supported, the last of which is the default. Note that this operation cannot be performed when a wallet is already loaded (unlocked). |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 201 | wallet created successfully | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
| 409 | Unable to complete request because object already exists. | |
||||
|
||||
### /wallet/{walletname}/unlock |
||||
|
||||
#### POST |
||||
##### Summary |
||||
|
||||
decrypt an existing wallet |
||||
|
||||
##### Description |
||||
|
||||
Give the password for the specified (existing) wallet file, and it will be decrypted ready for use. Note that this operation cannot be performed when another wallet is already loaded (unlocked). |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 200 | wallet unlocked successfully | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
| 404 | Item not found. | |
||||
| 409 | Unable to complete request because object already exists. | |
||||
|
||||
### /wallet/{walletname}/lock |
||||
|
||||
#### GET |
||||
##### Summary |
||||
|
||||
block access to a currently decrypted wallet |
||||
|
||||
##### Description |
||||
|
||||
After this (authenticated) action, the wallet will not be readable or writeable. |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 200 | wallet unlocked successfully | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
|
||||
##### Security |
||||
|
||||
| Security Schema | Scopes | |
||||
| --- | --- | |
||||
| bearerAuth | | |
||||
|
||||
### /wallet/{walletname}/display |
||||
|
||||
#### GET |
||||
##### Summary |
||||
|
||||
get detailed breakdown of wallet contents by account. |
||||
|
||||
##### Description |
||||
|
||||
get detailed breakdown of wallet contents by account. |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 200 | wallet display contents retrieved successfully. | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
| 404 | Item not found. | |
||||
|
||||
##### Security |
||||
|
||||
| Security Schema | Scopes | |
||||
| --- | --- | |
||||
| bearerAuth | | |
||||
|
||||
### /session |
||||
|
||||
#### GET |
||||
##### Summary |
||||
|
||||
get current status of backend |
||||
|
||||
##### Description |
||||
|
||||
get whether a wallet is loaded and whether coinjoin/maker are happening. |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 200 | successful heartbeat response | |
||||
|
||||
### /wallet/all |
||||
|
||||
#### GET |
||||
##### Summary |
||||
|
||||
get current available wallets |
||||
|
||||
##### Description |
||||
|
||||
get all wallet filenames in standard location as a list |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 200 | successful response to listwallets | |
||||
|
||||
### /wallet/{walletname}/address/new/{mixdepth} |
||||
|
||||
#### GET |
||||
##### Summary |
||||
|
||||
get a fresh address in the given account for depositing funds. |
||||
|
||||
##### Description |
||||
|
||||
get a fresh address in the given account for depositing funds. |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
| mixdepth | path | account or mixdepth to source the address from (0..4) | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 200 | successful retrieval of new address | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
| 404 | Item not found. | |
||||
|
||||
##### Security |
||||
|
||||
| Security Schema | Scopes | |
||||
| --- | --- | |
||||
| bearerAuth | | |
||||
|
||||
### /wallet/{walletname}/address/timelock/new/{lockdate} |
||||
|
||||
#### GET |
||||
##### Summary |
||||
|
||||
get a fresh timelock address |
||||
|
||||
##### Description |
||||
|
||||
get a new timelocked address, for depositing funds, to create a fidelity bond, which will automatically be used when the maker is started. specify the date in YYYY-mm as the last path parameter. Note that mixdepth is not specified as timelock addresses are always in mixdepth(account) zero. |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
| lockdate | path | month whose first day will be the end of the timelock, for this address. | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 200 | successful retrieval of new address | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
| 404 | Item not found. | |
||||
|
||||
##### Security |
||||
|
||||
| Security Schema | Scopes | |
||||
| --- | --- | |
||||
| bearerAuth | | |
||||
|
||||
### /wallet/{walletname}/utxos |
||||
|
||||
#### GET |
||||
##### Summary |
||||
|
||||
list details of all utxos currently in the wallet. |
||||
|
||||
##### Description |
||||
|
||||
list details of all utxos currently in the wallet. |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 200 | successful retrieval of utxo list | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
| 404 | Item not found. | |
||||
|
||||
##### Security |
||||
|
||||
| Security Schema | Scopes | |
||||
| --- | --- | |
||||
| bearerAuth | | |
||||
|
||||
### /wallet/{walletname}/taker/direct-send |
||||
|
||||
#### POST |
||||
##### Summary |
||||
|
||||
create and broadcast a transaction (without coinjoin) |
||||
|
||||
##### Description |
||||
|
||||
create and broadcast a transaction (without coinjoin) |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 200 | transaction broadcast OK. | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
| 409 | Transaction failed to broadcast. | |
||||
|
||||
##### Security |
||||
|
||||
| Security Schema | Scopes | |
||||
| --- | --- | |
||||
| bearerAuth | | |
||||
|
||||
### /wallet/{walletname}/maker/start |
||||
|
||||
#### POST |
||||
##### Summary |
||||
|
||||
Start the yield generator service. |
||||
|
||||
##### Description |
||||
|
||||
Start the yield generator service with the configuration settings specified in the POST request. Note that if fidelity bonds are enabled in the wallet, and a timelock address has been generated, and then funded, the fidelity bond will automatically be advertised without any specific configuration in this request. |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 202 | The request has been submitted successfully for processing, but the processing has not been completed. | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
| 503 | The server is not ready to process the request. | |
||||
|
||||
##### Security |
||||
|
||||
| Security Schema | Scopes | |
||||
| --- | --- | |
||||
| bearerAuth | | |
||||
|
||||
### /wallet/{walletname}/maker/stop |
||||
|
||||
#### GET |
||||
##### Summary |
||||
|
||||
stop the yield generator service |
||||
|
||||
##### Description |
||||
|
||||
stop the yield generator service |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 202 | The request has been submitted successfully for processing, but the processing has not been completed. | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
|
||||
##### Security |
||||
|
||||
| Security Schema | Scopes | |
||||
| --- | --- | |
||||
| bearerAuth | | |
||||
|
||||
### /wallet/{walletname}/taker/coinjoin |
||||
|
||||
#### POST |
||||
##### Summary |
||||
|
||||
initiate a coinjoin as taker |
||||
|
||||
##### Description |
||||
|
||||
initiate a coinjoin as taker |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 202 | The request has been submitted successfully for processing, but the processing has not been completed. | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
| 404 | Item not found. | |
||||
| 409 | Unable to complete request because config settings are missing. | |
||||
| 503 | The server is not ready to process the request. | |
||||
|
||||
##### Security |
||||
|
||||
| Security Schema | Scopes | |
||||
| --- | --- | |
||||
| bearerAuth | | |
||||
|
||||
### /wallet/{walletname}/configset |
||||
|
||||
#### POST |
||||
##### Summary |
||||
|
||||
change a config variable |
||||
|
||||
##### Description |
||||
|
||||
change a config variable (for the duration of this backend daemon process instance) |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 200 | successful update of config value | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
| 409 | Unable to complete request because config settings are missing. | |
||||
|
||||
##### Security |
||||
|
||||
| Security Schema | Scopes | |
||||
| --- | --- | |
||||
| bearerAuth | | |
||||
|
||||
### /wallet/{walletname}/configget |
||||
|
||||
#### POST |
||||
##### Summary |
||||
|
||||
get the value of a specific config setting |
||||
|
||||
##### Description |
||||
|
||||
Get the value of a specific config setting. Note values are always returned as string. |
||||
|
||||
##### Parameters |
||||
|
||||
| Name | Located in | Description | Required | Schema | |
||||
| ---- | ---------- | ----------- | -------- | ---- | |
||||
| walletname | path | name of wallet including .jmdat | Yes | string | |
||||
|
||||
##### Responses |
||||
|
||||
| Code | Description | |
||||
| ---- | ----------- | |
||||
| 200 | successful retrieval of config value | |
||||
| 400 | Bad request format. | |
||||
| 401 | Unable to authorise the credentials that were supplied. | |
||||
| 409 | Unable to complete request because config settings are missing. | |
||||
|
||||
##### Security |
||||
|
||||
| Security Schema | Scopes | |
||||
| --- | --- | |
||||
| bearerAuth | | |
||||
|
||||
### Models |
||||
|
||||
#### ConfigSetRequest |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| section | string | | Yes | |
||||
| field | string | | Yes | |
||||
| value | string | | Yes | |
||||
|
||||
#### ConfigGetRequest |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| section | string | | Yes | |
||||
| field | string | | Yes | |
||||
|
||||
#### ConfigGetResponse |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| configvalue | string | | Yes | |
||||
|
||||
#### ConfigSetResponse |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| ConfigSetResponse | object | | | |
||||
|
||||
#### DoCoinjoinRequest |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| mixdepth | integer | _Example:_ `0` | Yes | |
||||
| amount_sats | integer |_Example:_ `100000000` | Yes | |
||||
| counterparties | integer | _Example:_ `9` | Yes | |
||||
| destination | string | _Example:_ `"bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw"` | Yes | |
||||
|
||||
#### StartMakerRequest |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| txfee | string | _Example:_ `"0"` | Yes | |
||||
| cjfee_a | string |_Example:_ `"5000"` | Yes | |
||||
| cjfee_r | string |_Example:_ `"0.00004"` | Yes | |
||||
| ordertype | string | _Example:_ `"reloffer"` | Yes | |
||||
| minsize | string | _Example:_ `"8000000"` | Yes | |
||||
|
||||
#### GetAddressResponse |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| GetAddressResponse | string | | | |
||||
|
||||
**Example** |
||||
<pre>bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw</pre> |
||||
|
||||
#### ListWalletsResponse |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| wallets | [ string ] | | No | |
||||
|
||||
#### SessionResponse |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| session | boolean | | Yes | |
||||
| maker_running | boolean | | Yes | |
||||
| coinjoin_in_process | boolean | | Yes | |
||||
| wallet_name | string |_Example:_ `"wallet.jmdat"` | Yes | |
||||
|
||||
#### ListUtxosResponse |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| utxos | [ object ] | | No | |
||||
|
||||
#### WalletDisplayResponse |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| walletname | string | | Yes | |
||||
| walletinfo | object | | Yes | |
||||
|
||||
#### CreateWalletResponse |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| walletname | string | _Example:_ `"wallet.jmdat"` | Yes | |
||||
| token | byte | | Yes | |
||||
| seedphrase | string | | Yes | |
||||
|
||||
#### UnlockWalletResponse |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| walletname | string | _Example:_ `"wallet.jmdat"` | Yes | |
||||
| token | byte | | Yes | |
||||
|
||||
#### DirectSendResponse |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| txinfo | object | | Yes | |
||||
|
||||
#### LockWalletResponse |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| walletname | string | _Example:_ `"wallet.jmdat"` | Yes | |
||||
| already_locked | boolean |_Example:_ `false` | Yes | |
||||
|
||||
#### CreateWalletRequest |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| walletname | string | _Example:_ `"wallet.jmdat"` | Yes | |
||||
| password | password | _Example:_ `"hunter2"` | Yes | |
||||
| wallettype | string | _Example:_ `"sw-fb"` | Yes | |
||||
|
||||
#### UnlockWalletRequest |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| password | password | _Example:_ `"hunter2"` | Yes | |
||||
|
||||
#### DirectSendRequest |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| mixdepth | integer | _Example:_ `0` | Yes | |
||||
| amount_sats | integer |_Example:_ `100000000` | Yes | |
||||
| destination | string | _Example:_ `"bcrt1qu7k4dppungsqp95nwc7ansqs9m0z95h72j9mze"` | Yes | |
||||
|
||||
#### ErrorMessage |
||||
|
||||
| Name | Type | Description | Required | |
||||
| ---- | ---- | ----------- | -------- | |
||||
| message | string | | No | |
||||
@ -0,0 +1,820 @@
|
||||
openapi: 3.0.0 |
||||
info: |
||||
description: Joinmarket wallet API |
||||
version: "1" |
||||
title: Joinmarket wallet API |
||||
paths: |
||||
/wallet/create: |
||||
post: |
||||
summary: create a new wallet |
||||
operationId: createwallet |
||||
description: Give a filename (.jmdat must be included) and a password, create the wallet and get back the seedphrase for the newly persisted wallet file. The wallettype variable must be one of "sw" - segwit native, "sw-legacy" - segwit legacy or "sw-fb" - segwit native with fidelity bonds supported, the last of which is the default. Note that this operation cannot be performed when a wallet is already loaded (unlocked). |
||||
responses: |
||||
'201': |
||||
$ref: '#/components/responses/Create-201-OK' |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: '#/components/responses/401-Unauthorized' |
||||
'409': |
||||
$ref: '#/components/responses/409-AlreadyExists' |
||||
requestBody: |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/CreateWalletRequest' |
||||
description: wallet creation parameters |
||||
/wallet/{walletname}/unlock: |
||||
post: |
||||
summary: decrypt an existing wallet |
||||
operationId: unlockwallet |
||||
description: Give the password for the specified (existing) wallet file, and it will be decrypted ready for use. Note that this operation cannot be performed when another wallet is already loaded (unlocked). |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
responses: |
||||
'200': |
||||
$ref: "#/components/responses/Unlock-200-OK" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: '#/components/responses/401-Unauthorized' |
||||
'404': |
||||
$ref: '#/components/responses/404-NotFound' |
||||
'409': |
||||
$ref: '#/components/responses/409-AlreadyExists' |
||||
requestBody: |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/UnlockWalletRequest' |
||||
description: wallet unlocking parameters |
||||
/wallet/{walletname}/lock: |
||||
get: |
||||
security: |
||||
- bearerAuth: [] |
||||
summary: block access to a currently decrypted wallet |
||||
operationId: lockwallet |
||||
description: After this (authenticated) action, the wallet will not be readable or writeable. |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
responses: |
||||
'200': |
||||
$ref: "#/components/responses/Unlock-200-OK" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: '#/components/responses/401-Unauthorized' |
||||
/wallet/{walletname}/display: |
||||
get: |
||||
security: |
||||
- bearerAuth: [] |
||||
summary: get detailed breakdown of wallet contents by account. |
||||
operationId: displaywallet |
||||
description: get detailed breakdown of wallet contents by account. |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
responses: |
||||
'200': |
||||
$ref: "#/components/responses/Display-200-OK" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: '#/components/responses/401-Unauthorized' |
||||
'404': |
||||
$ref: '#/components/responses/404-NotFound' |
||||
/session: |
||||
get: |
||||
summary: get current status of backend |
||||
operationId: session |
||||
description: get whether a wallet is loaded and whether coinjoin/maker are happening. |
||||
responses: |
||||
'200': |
||||
$ref: "#/components/responses/Session-200-OK" |
||||
/wallet/all: |
||||
get: |
||||
summary: get current available wallets |
||||
operationId: listwallets |
||||
description: get all wallet filenames in standard location as a list |
||||
responses: |
||||
'200': |
||||
$ref: "#/components/responses/ListWallets-200-OK" |
||||
/wallet/{walletname}/address/new/{mixdepth}: |
||||
get: |
||||
security: |
||||
- bearerAuth: [] |
||||
summary: get a fresh address in the given account for depositing funds. |
||||
operationId: getaddress |
||||
description: get a fresh address in the given account for depositing funds. |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
- name: mixdepth |
||||
in: path |
||||
description: account or mixdepth to source the address from (0..4) |
||||
required: true |
||||
schema: |
||||
type: string |
||||
responses: |
||||
'200': |
||||
$ref: "#/components/responses/GetAddress-200-OK" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: '#/components/responses/401-Unauthorized' |
||||
'404': |
||||
$ref: '#/components/responses/404-NotFound' |
||||
/wallet/{walletname}/address/timelock/new/{lockdate}: |
||||
get: |
||||
security: |
||||
- bearerAuth: [] |
||||
summary: get a fresh timelock address |
||||
operationId: gettimelockaddress |
||||
description: get a new timelocked address, for depositing funds, to create a fidelity bond, which will automatically be used when the maker is started. specify the date in YYYY-mm as the last path parameter. Note that mixdepth is not specified as timelock addresses are always in mixdepth(account) zero. |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
example: wallet.jmdat |
||||
- name: lockdate |
||||
in: path |
||||
description: month whose first day will be the end of the timelock, for this address. |
||||
required: true |
||||
schema: |
||||
type: string # note- not a standard date-time string for OpenAPI, so not marked as such |
||||
example: "2021-09" |
||||
responses: |
||||
'200': |
||||
$ref: "#/components/responses/GetAddress-200-OK" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: '#/components/responses/401-Unauthorized' |
||||
'404': |
||||
$ref: '#/components/responses/404-NotFound' |
||||
/wallet/{walletname}/utxos: |
||||
get: |
||||
security: |
||||
- bearerAuth: [] |
||||
summary: list details of all utxos currently in the wallet. |
||||
operationId: listutxos |
||||
description: list details of all utxos currently in the wallet. |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
example: "2021-09" |
||||
responses: |
||||
'200': |
||||
$ref: "#/components/responses/ListUtxos-200-OK" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: '#/components/responses/401-Unauthorized' |
||||
'404': |
||||
$ref: '#/components/responses/404-NotFound' |
||||
/wallet/{walletname}/taker/direct-send: |
||||
post: |
||||
security: |
||||
- bearerAuth: [] |
||||
summary: create and broadcast a transaction (without coinjoin) |
||||
operationId: directsend |
||||
description: create and broadcast a transaction (without coinjoin) |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
requestBody: |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/DirectSendRequest' |
||||
description: transaction creation parameters |
||||
responses: |
||||
'200': |
||||
$ref: "#/components/responses/DirectSend-200-Accepted" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: '#/components/responses/401-Unauthorized' |
||||
'409': |
||||
$ref: '#/components/responses/409-TransactionFailed' |
||||
/wallet/{walletname}/maker/start: |
||||
post: |
||||
security: |
||||
- bearerAuth: [] |
||||
summary: Start the yield generator service. |
||||
operationId: startmaker |
||||
description: Start the yield generator service with the configuration settings specified in the POST request. Note that if fidelity bonds are enabled in the wallet, and a timelock address has been generated, and then funded, the fidelity bond will automatically be advertised without any specific configuration in this request. |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
example: wallet.jmdat |
||||
requestBody: |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/StartMakerRequest' |
||||
description: yield generator config parameters |
||||
responses: |
||||
# note we use a default response, no data returned: |
||||
'202': |
||||
$ref: "#/components/responses/202-Accepted" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: '#/components/responses/401-Unauthorized' |
||||
'503': |
||||
$ref: '#/components/responses/503-ServiceUnavailable' |
||||
/wallet/{walletname}/maker/stop: |
||||
get: |
||||
security: |
||||
- bearerAuth: [] |
||||
summary: stop the yield generator service |
||||
operationId: stopmaker |
||||
description: stop the yield generator service |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
responses: |
||||
'202': |
||||
$ref: "#/components/responses/202-Accepted" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: "#/components/responses/401-Unauthorized" |
||||
/wallet/{walletname}/taker/coinjoin: |
||||
post: |
||||
security: |
||||
- bearerAuth: [] |
||||
summary: initiate a coinjoin as taker |
||||
operationId: docoinjoin |
||||
description: initiate a coinjoin as taker |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
requestBody: |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/DoCoinjoinRequest' |
||||
description: taker side coinjoin parameters |
||||
responses: |
||||
'202': |
||||
$ref: "#/components/responses/202-Accepted" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: '#/components/responses/401-Unauthorized' |
||||
'404': |
||||
$ref: '#/components/responses/404-NotFound' |
||||
'409': |
||||
$ref: '#/components/responses/409-NoConfig' |
||||
'503': |
||||
$ref: '#/components/responses/503-ServiceUnavailable' |
||||
/wallet/{walletname}/configset: |
||||
post: |
||||
security: |
||||
- bearerAuth: [] |
||||
summary: change a config variable |
||||
operationId: configsetting |
||||
description: change a config variable (for the duration of this backend daemon process instance) |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
requestBody: |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ConfigSetRequest' |
||||
description: config editing parameters |
||||
responses: |
||||
'200': |
||||
$ref: "#/components/responses/ConfigSet-200-OK" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: '#/components/responses/401-Unauthorized' |
||||
'409': |
||||
$ref: '#/components/responses/409-NoConfig' |
||||
/wallet/{walletname}/configget: |
||||
post: |
||||
security: |
||||
- bearerAuth: [] |
||||
summary: get the value of a specific config setting |
||||
operationId: configget |
||||
description: Get the value of a specific config setting. Note values are always returned as string. |
||||
parameters: |
||||
- name: walletname |
||||
in: path |
||||
description: name of wallet including .jmdat |
||||
required: true |
||||
schema: |
||||
type: string |
||||
requestBody: |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ConfigGetRequest' |
||||
responses: |
||||
'200': |
||||
$ref: "#/components/responses/ConfigGet-200-OK" |
||||
'400': |
||||
$ref: '#/components/responses/400-BadRequest' |
||||
'401': |
||||
$ref: "#/components/responses/401-Unauthorized" |
||||
'409': |
||||
$ref: '#/components/responses/409-NoConfig' |
||||
components: |
||||
securitySchemes: |
||||
bearerAuth: |
||||
type: http |
||||
scheme: bearer |
||||
bearerFormat: JWT |
||||
schemas: |
||||
ConfigSetRequest: |
||||
type: object |
||||
required: |
||||
- section |
||||
- field |
||||
- value |
||||
properties: |
||||
section: |
||||
type: string |
||||
field: |
||||
type: string |
||||
value: |
||||
type: string |
||||
ConfigGetRequest: |
||||
type: object |
||||
required: |
||||
- section |
||||
- field |
||||
properties: |
||||
section: |
||||
type: string |
||||
field: |
||||
type: string |
||||
ConfigGetResponse: |
||||
type: object |
||||
required: |
||||
- configvalue |
||||
properties: |
||||
configvalue: |
||||
type: string |
||||
ConfigSetResponse: |
||||
type: object |
||||
DoCoinjoinRequest: |
||||
type: object |
||||
required: |
||||
- mixdepth |
||||
- amount_sats |
||||
- counterparties |
||||
- destination |
||||
properties: |
||||
mixdepth: |
||||
type: integer |
||||
example: 0 |
||||
amount_sats: |
||||
type: integer |
||||
example: 100000000 |
||||
counterparties: |
||||
type: integer |
||||
example: 9 |
||||
destination: |
||||
type: string |
||||
example: "bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw" |
||||
|
||||
StartMakerRequest: |
||||
type: object |
||||
required: |
||||
- txfee |
||||
- cjfee_a |
||||
- cjfee_r |
||||
- ordertype |
||||
- minsize |
||||
properties: |
||||
txfee: |
||||
type: string |
||||
example: "0" |
||||
cjfee_a: |
||||
type: string |
||||
example: "5000" |
||||
cjfee_r: |
||||
type: string |
||||
example: "0.00004" |
||||
ordertype: |
||||
type: string |
||||
example: "reloffer" |
||||
minsize: |
||||
type: string |
||||
example: "8000000" |
||||
GetAddressResponse: |
||||
type: string |
||||
example: "bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw" |
||||
ListWalletsResponse: |
||||
type: object |
||||
properties: |
||||
wallets: |
||||
type: array |
||||
items: |
||||
type: string |
||||
example: wallet.jmdat |
||||
SessionResponse: |
||||
type: object |
||||
required: |
||||
- session |
||||
- maker_running |
||||
- coinjoin_in_process |
||||
- wallet_name |
||||
properties: |
||||
session: |
||||
type: boolean |
||||
maker_running: |
||||
type: boolean |
||||
coinjoin_in_process: |
||||
type: boolean |
||||
wallet_name: |
||||
type: string |
||||
example: wallet.jmdat |
||||
ListUtxosResponse: |
||||
type: object |
||||
properties: |
||||
utxos: |
||||
type: array |
||||
items: |
||||
type: object |
||||
properties: |
||||
utxo: |
||||
type: string |
||||
address: |
||||
type: string |
||||
value: |
||||
type: integer |
||||
tries: |
||||
type: integer |
||||
tries_remaining: |
||||
type: integer |
||||
external: |
||||
type: boolean |
||||
mixdepth: |
||||
type: integer |
||||
confirmations: |
||||
type: integer |
||||
frozen: |
||||
type: boolean |
||||
WalletDisplayResponse: |
||||
type: object |
||||
required: |
||||
- walletname |
||||
- walletinfo |
||||
properties: |
||||
walletname: |
||||
type: string |
||||
walletinfo: |
||||
type: object |
||||
required: |
||||
- wallet_name |
||||
- total_balance |
||||
- accounts |
||||
properties: |
||||
wallet_name: |
||||
type: string |
||||
total_balance: |
||||
type: string |
||||
accounts: |
||||
type: array |
||||
items: |
||||
type: object |
||||
properties: |
||||
account: |
||||
type: string |
||||
account_balance: |
||||
type: string |
||||
branches: |
||||
type: array |
||||
items: |
||||
type: object |
||||
properties: |
||||
branch: |
||||
type: string |
||||
balance: |
||||
type: string |
||||
entries: |
||||
type: array |
||||
items: |
||||
type: object |
||||
properties: |
||||
hd_path: |
||||
type: string |
||||
address: |
||||
type: string |
||||
amount: |
||||
type: string |
||||
labels: |
||||
type: string |
||||
|
||||
CreateWalletResponse: |
||||
type: object |
||||
required: |
||||
- walletname |
||||
- token |
||||
- seedphrase |
||||
properties: |
||||
walletname: |
||||
type: string |
||||
example: wallet.jmdat |
||||
token: |
||||
type: string |
||||
format: byte |
||||
seedphrase: |
||||
type: string |
||||
UnlockWalletResponse: |
||||
type: object |
||||
required: |
||||
- walletname |
||||
- token |
||||
properties: |
||||
walletname: |
||||
type: string |
||||
example: wallet.jmdat |
||||
token: |
||||
type: string |
||||
format: byte |
||||
DirectSendResponse: |
||||
type: object |
||||
required: |
||||
- txinfo |
||||
properties: |
||||
txinfo: |
||||
type: object |
||||
properties: |
||||
hex: |
||||
type: string |
||||
inputs: |
||||
type: array |
||||
items: |
||||
type: object |
||||
properties: |
||||
outpoint: |
||||
type: string |
||||
scriptSig: |
||||
type: string |
||||
nSequence: |
||||
type: number |
||||
witness: |
||||
type: string |
||||
outputs: |
||||
type: array |
||||
items: |
||||
type: object |
||||
properties: |
||||
value_sats: |
||||
type: number |
||||
scriptPubKey: |
||||
type: string |
||||
address: |
||||
type: string |
||||
txid: |
||||
type: string |
||||
nLockTime: |
||||
type: number |
||||
nVersion: |
||||
type: number |
||||
LockWalletResponse: |
||||
type: object |
||||
required: |
||||
- walletname |
||||
- already_locked |
||||
properties: |
||||
walletname: |
||||
type: string |
||||
example: wallet.jmdat |
||||
already_locked: |
||||
type: boolean |
||||
example: false |
||||
CreateWalletRequest: |
||||
type: object |
||||
required: |
||||
- walletname |
||||
- password |
||||
- wallettype |
||||
properties: |
||||
walletname: |
||||
type: string |
||||
example: wallet.jmdat |
||||
password: |
||||
type: string |
||||
format: password |
||||
example: hunter2 |
||||
wallettype: |
||||
type: string |
||||
example: "sw-fb" |
||||
UnlockWalletRequest: |
||||
type: object |
||||
required: |
||||
- password |
||||
properties: |
||||
password: |
||||
type: string |
||||
format: password |
||||
example: hunter2 |
||||
DirectSendRequest: |
||||
type: object |
||||
required: |
||||
- mixdepth |
||||
- amount_sats |
||||
- destination |
||||
properties: |
||||
mixdepth: |
||||
type: integer |
||||
example: 0 |
||||
amount_sats: |
||||
type: integer |
||||
example: 100000000 |
||||
destination: |
||||
type: string |
||||
example: bcrt1qu7k4dppungsqp95nwc7ansqs9m0z95h72j9mze |
||||
ErrorMessage: |
||||
type: object |
||||
properties: |
||||
message: |
||||
type: string |
||||
|
||||
responses: |
||||
# Success responses |
||||
DirectSend-200-Accepted: |
||||
description: "transaction broadcast OK." |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: "#/components/schemas/DirectSendResponse" |
||||
ListUtxos-200-OK: |
||||
description: "successful retrieval of utxo list" |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: "#/components/schemas/ListUtxosResponse" |
||||
ConfigGet-200-OK: |
||||
description: "successful retrieval of config value" |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: "#/components/schemas/ConfigGetResponse" |
||||
ConfigSet-200-OK: |
||||
description: "successful update of config value" |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: "#/components/schemas/ConfigSetResponse" |
||||
GetAddress-200-OK: |
||||
description: "successful retrieval of new address" |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: "#/components/schemas/GetAddressResponse" |
||||
ListWallets-200-OK: |
||||
description: "successful response to listwallets" |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: "#/components/schemas/ListWalletsResponse" |
||||
Session-200-OK: |
||||
description: "successful heartbeat response" |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: "#/components/schemas/SessionResponse" |
||||
Create-201-OK: |
||||
description: "wallet created successfully" |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: "#/components/schemas/CreateWalletResponse" |
||||
Unlock-200-OK: |
||||
description: "wallet unlocked successfully" |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: "#/components/schemas/UnlockWalletResponse" |
||||
Display-200-OK: |
||||
description: "wallet display contents retrieved successfully." |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: "#/components/schemas/WalletDisplayResponse" |
||||
Lock-200-OK: |
||||
description: "wallet locked successfully" |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: "#/components/schemas/LockWalletResponse" |
||||
202-Accepted: |
||||
description: The request has been submitted successfully for processing, but the processing has not been completed. |
||||
204-NoResultFound: |
||||
description: No result found for matching search criteria. |
||||
# Clientside error responses |
||||
400-BadRequest: |
||||
description: Bad request format. |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ErrorMessage' |
||||
401-Unauthorized: |
||||
description: Unable to authorise the credentials that were supplied. |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ErrorMessage' |
||||
409-AlreadyExists: |
||||
description: Unable to complete request because object already exists. |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ErrorMessage' |
||||
409-NoConfig: |
||||
description: Unable to complete request because config settings are missing. |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ErrorMessage' |
||||
409-TransactionFailed: |
||||
description: Transaction failed to broadcast. |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ErrorMessage' |
||||
404-NotFound: |
||||
description: Item not found. |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ErrorMessage' |
||||
422-UnprocessableEntity: |
||||
description: Business rule validation failure. |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ErrorMessage' |
||||
429-TooManyRequests: |
||||
description: There are too many requests in a given amount of time. |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ErrorMessage' |
||||
# Serverside error responses |
||||
503-ServiceUnavailable: |
||||
description: The server is not ready to process the request. |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ErrorMessage' |
||||
5XX-UnexpectedError: |
||||
description: There was an internal issue calling the service. |
||||
content: |
||||
application/json: |
||||
schema: |
||||
$ref: '#/components/schemas/ErrorMessage' |
||||
@ -0,0 +1,830 @@
|
||||
from jmbitcoin import * |
||||
import datetime |
||||
import os |
||||
import json |
||||
import atexit |
||||
from io import BytesIO |
||||
from jmclient.wallet_utils import wallet_showutxos |
||||
from twisted.internet import reactor, ssl |
||||
from twisted.web.server import Site |
||||
from twisted.application.service import Service |
||||
from autobahn.twisted.websocket import listenWS |
||||
from klein import Klein |
||||
import jwt |
||||
|
||||
from jmbitcoin import human_readable_transaction |
||||
from jmclient import Taker, jm_single, \ |
||||
JMClientProtocolFactory, start_reactor, \ |
||||
WalletService, get_wallet_path, direct_send, \ |
||||
open_test_wallet_maybe, wallet_display, SegwitLegacyWallet, \ |
||||
SegwitWallet, get_daemon_serving_params, YieldGeneratorService, \ |
||||
create_wallet, get_max_cj_fee_values, \ |
||||
StorageError, StoragePasswordError, JmwalletdWebSocketServerFactory, \ |
||||
JmwalletdWebSocketServerProtocol, RetryableStorageError, \ |
||||
SegwitWalletFidelityBonds, wallet_gettimelockaddress, \ |
||||
YieldGeneratorServiceSetupFailed |
||||
from jmbase.support import get_log |
||||
|
||||
jlog = get_log() |
||||
|
||||
api_version_string = "/api/v1" |
||||
|
||||
# for debugging; twisted.web.server.Request objects do not easily serialize: |
||||
def print_req(request): |
||||
print(request) |
||||
print(request.method) |
||||
print(request.uri) |
||||
print(request.args) |
||||
print(request.path) |
||||
print(request.content) |
||||
print(list(request.requestHeaders.getAllRawHeaders())) |
||||
|
||||
class NotAuthorized(Exception): |
||||
pass |
||||
|
||||
class NoWalletFound(Exception): |
||||
pass |
||||
|
||||
class InvalidRequestFormat(Exception): |
||||
pass |
||||
|
||||
class BackendNotReady(Exception): |
||||
pass |
||||
|
||||
# error class for services which are only |
||||
# started once: |
||||
class ServiceAlreadyStarted(Exception): |
||||
pass |
||||
|
||||
# for the special case of the wallet service: |
||||
class WalletAlreadyUnlocked(Exception): |
||||
pass |
||||
|
||||
# in wallet creation, if the file exists: |
||||
class WalletAlreadyExists(Exception): |
||||
pass |
||||
|
||||
# if the file cannot be created or opened |
||||
# due to existing lock: |
||||
class LockExists(Exception): |
||||
pass |
||||
|
||||
# some actions require configuration variables |
||||
# to proceed (related to fees, in particular); |
||||
# if those are not allowed to fall back to defaults, |
||||
# we return an error: |
||||
class ConfigNotPresent(Exception): |
||||
pass |
||||
|
||||
class ServiceNotStarted(Exception): |
||||
pass |
||||
|
||||
# raised when a requested transaction did |
||||
# not successfully broadcast. |
||||
class TransactionFailed(Exception): |
||||
pass |
||||
|
||||
def get_ssl_context(cert_directory): |
||||
"""Construct an SSL context factory from the user's privatekey/cert. |
||||
TODO: |
||||
Currently just hardcoded for tests. |
||||
""" |
||||
return ssl.DefaultOpenSSLContextFactory(os.path.join(cert_directory, "key.pem"), |
||||
os.path.join(cert_directory, "cert.pem")) |
||||
|
||||
def make_jmwalletd_response(request, status=200, **kwargs): |
||||
""" |
||||
Build the response body as JSON and set the proper content-type |
||||
header. |
||||
""" |
||||
request.setHeader('Content-Type', 'application/json') |
||||
request.setHeader('Access-Control-Allow-Origin', '*') |
||||
request.setHeader("Cache-Control", "no-cache, must-revalidate") |
||||
request.setHeader("Pragma", "no-cache") |
||||
request.setHeader("Expires", "Sat, 26 Jul 1997 05:00:00 GMT") |
||||
request.setResponseCode(status) |
||||
return json.dumps(kwargs) |
||||
|
||||
CJ_TAKER_RUNNING, CJ_MAKER_RUNNING, CJ_NOT_RUNNING = range(3) |
||||
|
||||
class JMWalletDaemon(Service): |
||||
""" This class functions as an HTTP/TLS server, |
||||
with acccess control, allowing a single client(user) |
||||
to control functioning of encapsulated Joinmarket services. |
||||
""" |
||||
|
||||
app = Klein() |
||||
def __init__(self, port, wss_port, tls=True): |
||||
""" Port is the port to serve this daemon |
||||
(using HTTP/TLS). |
||||
wss_factory is a twisted protocol factory for the |
||||
websocket connections for clients to subscribe to updates. |
||||
""" |
||||
# cookie tracks single user's state. |
||||
self.cookie = None |
||||
self.port = port |
||||
self.wss_port = wss_port |
||||
self.tls = tls |
||||
pref = "wss" if self.tls else "ws" |
||||
self.wss_url = pref + "://127.0.0.1:" + str(wss_port) |
||||
# the collection of services which this |
||||
# daemon may switch on and off: |
||||
self.services = {} |
||||
# master single wallet service which we |
||||
# allow the client to start/stop. |
||||
self.services["wallet"] = None |
||||
self.wallet_name = "None" |
||||
# label for convenience: |
||||
self.wallet_service = self.services["wallet"] |
||||
# Client may start other services, but only |
||||
# one instance. |
||||
self.services["snicker"] = None |
||||
self.services["maker"] = None |
||||
# our taker object will handle doing sends/taker-cjs: |
||||
self.taker = None |
||||
# the factory of type JmwalletdWebsocketServerFactory, |
||||
# which has notification methods that can be passed |
||||
# as callbacks for in-wallet events: |
||||
self.wss_factory = None |
||||
# keep track of whether we're running actively as maker |
||||
# or taker: |
||||
self.coinjoin_state = CJ_NOT_RUNNING |
||||
# keep track of client side connections so they |
||||
# can be shut down cleanly: |
||||
self.coinjoin_connection = None |
||||
# ensure shut down does not leave dangling services: |
||||
atexit.register(self.stopService) |
||||
|
||||
def activate_coinjoin_state(self, state): |
||||
""" To be set when a maker or taker |
||||
operation is initialized; they cannot |
||||
both operate at once, nor can we run repeated |
||||
instances of either (hence 'activate' rather than 'set'). |
||||
Since running the maker means running the |
||||
YieldGeneratorService, the start and stop of that service |
||||
is encapsulated here. |
||||
Returns: |
||||
True if and only if the switching on of the chosen state |
||||
(including the 'switching on' of the 'not running' state!) |
||||
was actually enacted. If the new chosen state cannot be |
||||
switched on, returns False. |
||||
""" |
||||
assert state in [CJ_MAKER_RUNNING, CJ_TAKER_RUNNING, CJ_NOT_RUNNING] |
||||
if state == self.coinjoin_state: |
||||
# cannot re-active currently active state, as per above; |
||||
# note that this rejects switching "off" when we're already |
||||
# off. |
||||
return False |
||||
elif self.coinjoin_state == CJ_NOT_RUNNING: |
||||
self.coinjoin_state = state |
||||
self.wss_factory.sendCoinjoinStatusUpdate(self.coinjoin_state) |
||||
return True |
||||
elif state == CJ_NOT_RUNNING: |
||||
# currently active, switching off. |
||||
self.coinjoin_state = state |
||||
self.wss_factory.sendCoinjoinStatusUpdate(self.coinjoin_state) |
||||
return True |
||||
# anything else is a conflict and we can't change: |
||||
return False |
||||
|
||||
def startService(self): |
||||
""" Encapsulates start up actions. |
||||
Here starting the TLS server. |
||||
""" |
||||
super().startService() |
||||
# we do not auto-start any service, including the base |
||||
# wallet service, since the client must actively request |
||||
# that with the appropriate credential (password). |
||||
# initialise the web socket service for subscriptions |
||||
self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url) |
||||
self.wss_factory.protocol = JmwalletdWebSocketServerProtocol |
||||
if self.tls: |
||||
cf = get_ssl_context(os.path.join(jm_single().datadir, "ssl")) |
||||
listener_rpc = reactor.listenSSL(self.port, Site( |
||||
self.app.resource()), contextFactory=cf) |
||||
listener_ws = listenWS(self.wss_factory, contextFactory=cf) |
||||
else: |
||||
listener_rpc = reactor.listenTCP(self.port, Site( |
||||
self.app.resource())) |
||||
listener_ws = listenWS(self.wss_factory, contextFactory=None) |
||||
return (listener_rpc, listener_ws) |
||||
|
||||
def stopService(self): |
||||
""" Encapsulates shut down actions. |
||||
""" |
||||
# Currently valid authorization tokens must be removed |
||||
# from the daemon: |
||||
self.cookie = None |
||||
# if the wallet-daemon is shut down, all services |
||||
# it encapsulates must also be shut down. |
||||
for name, service in self.services.items(): |
||||
if service: |
||||
service.stopService() |
||||
super().stopService() |
||||
|
||||
def err(self, request, message): |
||||
""" Return errors in a standard format. |
||||
""" |
||||
request.setHeader('Content-Type', 'application/json') |
||||
return json.dumps({"message": message}) |
||||
|
||||
@app.handle_errors(NotAuthorized) |
||||
def not_authorized(self, request, failure): |
||||
request.setResponseCode(401) |
||||
return self.err(request, "Invalid credentials.") |
||||
|
||||
@app.handle_errors(NoWalletFound) |
||||
def no_wallet_found(self, request, failure): |
||||
request.setResponseCode(404) |
||||
return self.err(request, "No wallet loaded.") |
||||
|
||||
@app.handle_errors(BackendNotReady) |
||||
def backend_not_ready(self, request, failure): |
||||
request.setResponseCode(503) |
||||
return self.err(request, "Backend daemon not available") |
||||
|
||||
@app.handle_errors(InvalidRequestFormat) |
||||
def invalid_request_format(self, request, failure): |
||||
request.setResponseCode(400) |
||||
return self.err(request, "Invalid request format.") |
||||
|
||||
@app.handle_errors(ServiceAlreadyStarted) |
||||
def service_already_started(self, request, failure): |
||||
request.setResponseCode(401) |
||||
return self.err(request, "Service already started.") |
||||
|
||||
@app.handle_errors(WalletAlreadyUnlocked) |
||||
def wallet_already_unlocked(self, request, failure): |
||||
request.setResponseCode(401) |
||||
return self.err(request, "Wallet already unlocked.") |
||||
|
||||
@app.handle_errors(WalletAlreadyExists) |
||||
def wallet_already_exists(self, request, failure): |
||||
request.setResponseCode(409) |
||||
return self.err(request, "Wallet file cannot be overwritten.") |
||||
|
||||
@app.handle_errors(LockExists) |
||||
def lock_exists(self, request, failure): |
||||
request.setResponseCode(409) |
||||
return self.err(request, |
||||
"Wallet cannot be created/opened, it is locked.") |
||||
|
||||
@app.handle_errors(ConfigNotPresent) |
||||
def config_not_present(self, request, failure): |
||||
request.setResponseCode(409) |
||||
return self.err(request, |
||||
"Action cannot be performed, config vars are not set.") |
||||
|
||||
@app.handle_errors(ServiceNotStarted) |
||||
def service_not_started(self, request, failure): |
||||
request.setResponseCode(401) |
||||
return self.err(request, |
||||
"Service cannot be stopped as it is not running.") |
||||
|
||||
@app.handle_errors(TransactionFailed) |
||||
def transaction_failed(self, request, failure): |
||||
# TODO 409 as 'conflicted state' may not be ideal? |
||||
request.setResponseCode(409) |
||||
return self.err(request, "Transaction failed.") |
||||
|
||||
def check_cookie(self, request): |
||||
#part after bearer is what we need |
||||
try: |
||||
auth_header=((request.getHeader('Authorization'))) |
||||
request_cookie = None |
||||
if auth_header is not None: |
||||
request_cookie=auth_header[7:] |
||||
except Exception: |
||||
# deliberately catching anything |
||||
raise NotAuthorized() |
||||
if request_cookie==None or self.cookie != request_cookie: |
||||
jlog.warn("Invalid cookie: " + str( |
||||
request_cookie) + ", request rejected.") |
||||
raise NotAuthorized() |
||||
|
||||
def get_POST_body(self, request, keys): |
||||
""" given a request object, retrieve values corresponding |
||||
to keys keys in a dict, assuming they were encoded using JSON. |
||||
If *any* of the keys are not present, return False, else |
||||
returns a dict of those key-value pairs. |
||||
""" |
||||
assert isinstance(request.content, BytesIO) |
||||
# we swallow any formatting failure here: |
||||
try: |
||||
json_data = json.loads(request.content.read().decode( |
||||
"utf-8")) |
||||
return {k: json_data[k] for k in keys} |
||||
except: |
||||
return False |
||||
|
||||
def initialize_wallet_service(self, request, wallet, wallet_name, **kwargs): |
||||
""" Called only when the wallet has loaded correctly, so |
||||
authorization is passed, so set cookie for this wallet |
||||
(currently THE wallet, daemon does not yet support multiple). |
||||
This is maintained for 30 minutes currently, or until the user |
||||
switches to a new wallet. |
||||
Here we must also register transaction update callbacks, to fire |
||||
events in the websocket connection. |
||||
""" |
||||
# any random secret is OK, as long as it is not deducible/predictable: |
||||
secret_key = bintohex(os.urandom(16)) |
||||
encoded_token = jwt.encode({"wallet": wallet_name, |
||||
"exp" :datetime.datetime.utcnow( |
||||
)+datetime.timedelta(minutes=30)}, |
||||
secret_key) |
||||
encoded_token = encoded_token.strip() |
||||
self.cookie = encoded_token |
||||
if self.cookie is None: |
||||
raise NotAuthorized("No cookie") |
||||
self.wallet_service = WalletService(wallet) |
||||
# restart callback needed, otherwise wallet creation will |
||||
# automatically lead to shutdown. |
||||
# TODO: this means that it's possible, in non-standard usage |
||||
# patterns, for the sync to complete without a full record of |
||||
# balances; there are various approaches to passing warnings |
||||
# or requesting rescans, none are implemented yet. |
||||
def dummy_restart_callback(msg): |
||||
jlog.warn("Ignoring rescan request from backend wallet service: " + msg) |
||||
self.wallet_service.add_restart_callback(dummy_restart_callback) |
||||
self.wallet_name = wallet_name |
||||
# the daemon blocks here until the wallet synchronization |
||||
# from the blockchain interface completes; currently this is |
||||
# fine as long as the client handles the response asynchronously: |
||||
while not self.wallet_service.synced: |
||||
self.wallet_service.sync_wallet(fast=True) |
||||
self.wallet_service.register_callbacks( |
||||
[self.wss_factory.sendTxNotification], None) |
||||
self.wallet_service.startService() |
||||
# now that the service is intialized, we want to |
||||
# make sure that any websocket clients use the correct |
||||
# token: |
||||
self.wss_factory.valid_token = encoded_token |
||||
# now that the WalletService instance is active and ready to |
||||
# respond to requests, we return the status to the client: |
||||
if('seedphrase' in kwargs): |
||||
return make_jmwalletd_response(request, |
||||
walletname=self.wallet_name, |
||||
token=encoded_token, |
||||
seedphrase=kwargs.get('seedphrase')) |
||||
else: |
||||
return make_jmwalletd_response(request, |
||||
walletname=self.wallet_name, |
||||
token=encoded_token) |
||||
|
||||
def taker_finished(self, res, fromtx=False, waittime=0.0, txdetails=None): |
||||
# This is a slimmed down version compared with what is seen in |
||||
# the CLI code, since that code encompasses schedules with multiple |
||||
# entries; for now, the RPC only supports single joins. |
||||
# TODO this may be updated. |
||||
# It is also different in that the event loop must not shut down |
||||
# when processing finishes. |
||||
assert fromtx is False |
||||
if not res: |
||||
jlog.info("Coinjoin did not complete successfully.") |
||||
#Should usually be unreachable, unless conf received out of order; |
||||
#because we should stop on 'unconfirmed' for last (see above) |
||||
else: |
||||
jlog.info("Coinjoin completed correctly") |
||||
# reset our state on completion, we are no longer coinjoining: |
||||
self.taker = None |
||||
# Note; it's technically possible for this to return False if somehow |
||||
# we are currently in inactive state, but it isn't an error: |
||||
self.activate_coinjoin_state(CJ_NOT_RUNNING) |
||||
# remove dangling connections |
||||
if self.clientfactory: |
||||
self.clientfactory.proto_client.request_mc_shutdown() |
||||
if self.coinjoin_connection: |
||||
try: |
||||
self.coinjoin_connection.disconnect() |
||||
# note that "serverconn" here is the jm messaging daemon, |
||||
# listening for new connections, so we don't shut it down |
||||
# as both makers and takers will assume it's started up. |
||||
except Exception as e: |
||||
# Should not happen, but avoid crash if trying to |
||||
# shut down something that already disconnected: |
||||
jlog.warn("Failed to shut down connection: " + repr(e)) |
||||
self.coinjoin_connection = None |
||||
|
||||
def filter_orders_callback(self,orderfees, cjamount): |
||||
""" Currently we rely on the user's fee limit choices |
||||
and don't allow them to inspect the offers before acceptance. |
||||
TODO: two phase response to client. |
||||
""" |
||||
return True |
||||
|
||||
def check_daemon_ready(self): |
||||
# daemon must be up before coinjoins start. |
||||
daemon_serving_host, daemon_serving_port = get_daemon_serving_params() |
||||
if daemon_serving_port == -1 or daemon_serving_host == "": |
||||
raise BackendNotReady() |
||||
return (daemon_serving_host, daemon_serving_port) |
||||
|
||||
""" RPC begins here. |
||||
""" |
||||
|
||||
# handling CORS preflight for any route: |
||||
# TODO is this ever needed? |
||||
@app.route('/', branch=True, methods=['OPTIONS']) |
||||
def preflight(self, request): |
||||
request.setHeader("Access-Control-Allow-Origin", "*") |
||||
request.setHeader("Access-Control-Allow-Methods", "POST") |
||||
|
||||
with app.subroute(api_version_string) as app: |
||||
@app.route('/wallet/<string:walletname>/display', methods=['GET']) |
||||
def displaywallet(self, request, walletname): |
||||
print_req(request) |
||||
self.check_cookie(request) |
||||
if not self.wallet_service: |
||||
jlog.warn("displaywallet called, but no wallet loaded") |
||||
raise NoWalletFound() |
||||
if not self.wallet_name == walletname: |
||||
jlog.warn("called displaywallet with wrong wallet") |
||||
raise InvalidRequestFormat() |
||||
else: |
||||
walletinfo = wallet_display(self.wallet_service, False, jsonified=True) |
||||
return make_jmwalletd_response(request, walletname=walletname, walletinfo=walletinfo) |
||||
|
||||
@app.route('/session', methods=['GET']) |
||||
def session(self, request): |
||||
""" This route functions as a heartbeat, and communicates |
||||
to the client what the current status of the wallet |
||||
and services is. TODO: add more data to send to client. |
||||
""" |
||||
#if no wallet loaded then clear frontend session info |
||||
#when no wallet status is false |
||||
session = not self.cookie==None |
||||
maker_running = self.coinjoin_state == CJ_MAKER_RUNNING |
||||
coinjoin_in_process = self.coinjoin_state == CJ_TAKER_RUNNING |
||||
if self.wallet_service: |
||||
if self.wallet_service.isRunning(): |
||||
wallet_name = self.wallet_name |
||||
else: |
||||
wallet_name = "not yet loaded" |
||||
else: |
||||
wallet_name = "None" |
||||
return make_jmwalletd_response(request,session=session, |
||||
maker_running=maker_running, |
||||
coinjoin_in_process=coinjoin_in_process, |
||||
wallet_name=wallet_name) |
||||
|
||||
@app.route('/wallet/<string:walletname>/taker/direct-send', methods=['POST']) |
||||
def directsend(self, request, walletname): |
||||
""" Use the contents of the POST body to do a direct send from |
||||
the active wallet at the chosen mixdepth. |
||||
""" |
||||
self.check_cookie(request) |
||||
assert isinstance(request.content, BytesIO) |
||||
payment_info_json = self.get_POST_body(request, ["mixdepth", "amount_sats", |
||||
"destination"]) |
||||
if not payment_info_json: |
||||
raise InvalidRequestFormat() |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
if not self.wallet_name == walletname: |
||||
raise InvalidRequestFormat() |
||||
try: |
||||
tx = direct_send(self.wallet_service, |
||||
int(payment_info_json["amount_sats"]), |
||||
int(payment_info_json["mixdepth"]), |
||||
destination=payment_info_json["destination"], |
||||
return_transaction=True, answeryes=True) |
||||
except AssertionError: |
||||
raise InvalidRequestFormat() |
||||
if not tx: |
||||
# this should not really happen; not a coinjoin |
||||
# so tx should go through. |
||||
raise TransactionFailed() |
||||
return make_jmwalletd_response(request, |
||||
txinfo=human_readable_transaction(tx, False)) |
||||
|
||||
@app.route('/wallet/<string:walletname>/maker/start', methods=['POST']) |
||||
def start_maker(self, request, walletname): |
||||
""" Use the configuration in the POST body to start the yield generator: |
||||
""" |
||||
print_req(request) |
||||
self.check_cookie(request) |
||||
assert isinstance(request.content, BytesIO) |
||||
config_json = self.get_POST_body(request, ["txfee", "cjfee_a", "cjfee_r", |
||||
"ordertype", "minsize"]) |
||||
if not config_json: |
||||
raise InvalidRequestFormat() |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
if not self.wallet_name == walletname: |
||||
raise InvalidRequestFormat() |
||||
|
||||
dhost, dport = self.check_daemon_ready() |
||||
|
||||
for key, val in config_json.items(): |
||||
if(key == 'cjfee_r' or key == 'ordertype'): |
||||
pass |
||||
else: |
||||
try: |
||||
config_json[key] = int(config_json[key]) |
||||
except ValueError: |
||||
raise InvalidRequestFormat() |
||||
# these fields are not used by the "basic" yg. |
||||
# TODO "upgrade" this to yg-privacyenhanced type. |
||||
config_json['txfee_factor'] = None |
||||
config_json["cjfee_factor"] = None |
||||
config_json["size_factor"] = None |
||||
|
||||
self.services["maker"] = YieldGeneratorService(self.wallet_service, |
||||
dhost, dport, |
||||
[config_json[x] for x in ["txfee", "cjfee_a", |
||||
"cjfee_r", "ordertype", "minsize", |
||||
"txfee_factor", "cjfee_factor","size_factor"]]) |
||||
# make sure that our state here is consistent with any unexpected |
||||
# shutdown of the maker (such as from a invalid minsize causing startup |
||||
# to fail): |
||||
def cleanup(): |
||||
self.activate_coinjoin_state(CJ_NOT_RUNNING) |
||||
def setup(): |
||||
# note this returns False if we cannot update the state. |
||||
return self.activate_coinjoin_state(CJ_MAKER_RUNNING) |
||||
self.services["maker"].addCleanup(cleanup) |
||||
self.services["maker"].addSetup(setup) |
||||
# Service startup now checks and updates coinjoin state: |
||||
try: |
||||
self.services["maker"].startService() |
||||
except YieldGeneratorServiceSetupFailed: |
||||
raise ServiceAlreadyStarted() |
||||
return make_jmwalletd_response(request) |
||||
|
||||
@app.route('/wallet/<string:walletname>/maker/stop', methods=['GET']) |
||||
def stop_maker(self, request, walletname): |
||||
self.check_cookie(request) |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
if not self.wallet_name == walletname: |
||||
raise InvalidRequestFormat() |
||||
if not self.services["maker"] or not self.coinjoin_state == \ |
||||
CJ_MAKER_RUNNING: |
||||
raise ServiceNotStarted() |
||||
self.services["maker"].stopService() |
||||
return make_jmwalletd_response(request) |
||||
|
||||
@app.route('/wallet/<string:walletname>/lock', methods=['GET']) |
||||
def lockwallet(self, request, walletname): |
||||
print_req(request) |
||||
self.check_cookie(request) |
||||
if self.wallet_service and not self.wallet_name == walletname: |
||||
raise InvalidRequestFormat() |
||||
if not self.wallet_service: |
||||
jlog.warn("Called lock, but no wallet loaded") |
||||
# we could raise NoWalletFound here, but is |
||||
# easier for clients if they can gracefully call |
||||
# lock multiple times: |
||||
already_locked = True |
||||
else: |
||||
self.wallet_service.stopService() |
||||
self.cookie = None |
||||
self.wss_factory.valid_token = None |
||||
self.wallet_service = None |
||||
already_locked = False |
||||
return make_jmwalletd_response(request, walletname=walletname, |
||||
already_locked=already_locked) |
||||
|
||||
@app.route('/wallet/create', methods=["POST"]) |
||||
def createwallet(self, request): |
||||
print_req(request) |
||||
# we only handle one wallet at a time; |
||||
# if there is a currently unlocked wallet, |
||||
# refuse to process the request: |
||||
if self.wallet_service: |
||||
raise WalletAlreadyUnlocked() |
||||
request_data = self.get_POST_body(request, |
||||
["walletname", "password", "wallettype"]) |
||||
if not request_data: |
||||
raise InvalidRequestFormat() |
||||
wallettype = request_data["wallettype"] |
||||
if wallettype == "sw": |
||||
wallet_cls = SegwitWallet |
||||
elif wallettype == "sw-legacy": |
||||
wallet_cls = SegwitLegacyWallet |
||||
elif wallettype == "sw-fb": |
||||
wallet_cls = SegwitWalletFidelityBonds |
||||
else: |
||||
raise InvalidRequestFormat() |
||||
# use the config's data location combined with the json |
||||
# data to construct the wallet path: |
||||
wallet_root_path = os.path.join(jm_single().datadir, "wallets") |
||||
wallet_name = os.path.join(wallet_root_path, |
||||
request_data["walletname"]) |
||||
try: |
||||
wallet = create_wallet(wallet_name, |
||||
request_data["password"].encode("ascii"), |
||||
4, wallet_cls=wallet_cls) |
||||
# extension not yet supported in RPC create; TODO |
||||
seed, extension = wallet.get_mnemonic_words() |
||||
except RetryableStorageError: |
||||
raise LockExists() |
||||
except StorageError: |
||||
raise WalletAlreadyExists() |
||||
# finally, after the wallet is successfully created, we should |
||||
# start the wallet service, then return info to the caller: |
||||
return self.initialize_wallet_service(request, wallet, |
||||
request_data["walletname"], |
||||
seedphrase=seed) |
||||
|
||||
@app.route('/wallet/<string:walletname>/unlock', methods=['POST']) |
||||
def unlockwallet(self, request, walletname): |
||||
""" If a user succeeds in authenticating and opening a |
||||
wallet, we start the corresponding wallet service. |
||||
""" |
||||
print_req(request) |
||||
assert isinstance(request.content, BytesIO) |
||||
auth_json = self.get_POST_body(request, ["password"]) |
||||
if not auth_json: |
||||
raise InvalidRequestFormat() |
||||
password = auth_json["password"] |
||||
if self.wallet_service is None: |
||||
wallet_path = get_wallet_path(walletname, None) |
||||
try: |
||||
wallet = open_test_wallet_maybe( |
||||
wallet_path, walletname, 4, |
||||
password=password.encode("utf-8"), |
||||
ask_for_password=False) |
||||
except StoragePasswordError: |
||||
raise NotAuthorized() |
||||
except RetryableStorageError: |
||||
# .lock file exists |
||||
raise LockExists() |
||||
except StorageError: |
||||
# wallet is not openable |
||||
raise NoWalletFound() |
||||
except Exception: |
||||
# wallet file doesn't exist or is wrong format |
||||
raise NoWalletFound() |
||||
return self.initialize_wallet_service(request, wallet, walletname) |
||||
else: |
||||
jlog.warn('Tried to unlock wallet, but one is already unlocked.') |
||||
jlog.warn('Currently only one active wallet at a time is supported.') |
||||
raise WalletAlreadyUnlocked() |
||||
|
||||
#This route should return list of current wallets created. |
||||
@app.route('/wallet/all', methods=['GET']) |
||||
def listwallets(self, request): |
||||
wallet_dir = os.path.join(jm_single().datadir, 'wallets') |
||||
# TODO: we only allow .jmdat files, and assume they |
||||
# are actually wallets; but we should validate these |
||||
# wallet files before returning them (though JM itself |
||||
# never puts any other kind of file in this directory, |
||||
# the user conceivably might). |
||||
if not os.path.exists(wallet_dir): |
||||
wallets = [] |
||||
else: |
||||
wallets = os.listdir(wallet_dir) |
||||
wallets = [w for w in wallets if w.endswith("jmdat")] |
||||
return make_jmwalletd_response(request, wallets=wallets) |
||||
|
||||
#route to get external address for deposit |
||||
@app.route('/wallet/<string:walletname>/address/new/<string:mixdepth>', methods=['GET']) |
||||
def getaddress(self, request, walletname, mixdepth): |
||||
self.check_cookie(request) |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
if not self.wallet_name == walletname: |
||||
raise InvalidRequestFormat() |
||||
try: |
||||
mixdepth = int(mixdepth) |
||||
except ValueError: |
||||
raise InvalidRequestFormat() |
||||
address = self.wallet_service.get_external_addr(mixdepth) |
||||
return make_jmwalletd_response(request, address=address) |
||||
|
||||
@app.route('/wallet/<string:walletname>/address/timelock/new/<string:lockdate>', methods=['GET']) |
||||
def gettimelockaddress(self, request, walletname): |
||||
self.check_cookie(request) |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
if not self.wallet_name == walletname: |
||||
raise InvalidRequestFormat() |
||||
try: |
||||
timelockaddress = wallet_gettimelockaddress(self.wallet_service, |
||||
lockdate) |
||||
except Exception as e: |
||||
return InvalidRequestFormat() |
||||
if timelockaddress == "": |
||||
return InvalidRequestFormat() |
||||
return make_jmwalletd_response(request, address=address) |
||||
|
||||
@app.route('/wallet/<string:walletname>/configget', methods=["POST"]) |
||||
def configget(self, request, walletname): |
||||
""" Note that this requires authentication but is not wallet-specific. |
||||
Note also that return values are always strings. |
||||
""" |
||||
self.check_cookie(request) |
||||
# This is more just a sanity check; if user is using the wrong |
||||
# walletname but the right token, something has gone very wrong: |
||||
if not self.wallet_name == walletname: |
||||
raise InvalidRequestFormat() |
||||
config_json = self.get_POST_body(request, ["section", "field"]) |
||||
if not config_json: |
||||
raise InvalidRequestFormat() |
||||
try: |
||||
val = jm_single().config.get(config_json["section"], |
||||
config_json["field"]) |
||||
except: |
||||
# assuming failure here is a badly formed section/field: |
||||
raise ConfigNotPresent() |
||||
return make_jmwalletd_response(request, configvalue=val) |
||||
|
||||
@app.route('/wallet/<string:walletname>/configset', methods=["POST"]) |
||||
def configset(self, request, walletname): |
||||
""" Note that this requires authentication but is not wallet-specific. |
||||
Note also that supplied values must always be strings. |
||||
""" |
||||
self.check_cookie(request) |
||||
# This is more just a sanity check; if user is using the wrong |
||||
# walletname but the right token, something has gone very wrong: |
||||
if not self.wallet_name == walletname: |
||||
raise InvalidRequestFormat() |
||||
config_json = self.get_POST_body(request, ["section", "field", "value"]) |
||||
if not config_json: |
||||
raise InvalidRequestFormat() |
||||
try: |
||||
jm_single().config.set(config_json["section"], |
||||
config_json["field"], config_json["value"]) |
||||
except: |
||||
raise ConfigNotPresent() |
||||
# null return indicates success in updating: |
||||
return make_jmwalletd_response(request) |
||||
|
||||
def get_listutxos_response(self, utxos): |
||||
res = [] |
||||
for k, v in utxos.items(): |
||||
v["utxo"] = k |
||||
res.append(v) |
||||
return res |
||||
|
||||
#route to list utxos |
||||
@app.route('/wallet/<string:walletname>/utxos',methods=['GET']) |
||||
def listutxos(self, request, walletname): |
||||
self.check_cookie(request) |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
if not self.wallet_name == walletname: |
||||
raise InvalidRequestFormat() |
||||
# note: the output of `showutxos` is already a string for CLI; |
||||
# but we return json: |
||||
utxos = json.loads(wallet_showutxos(self.wallet_service, False)) |
||||
utxos_response = self.get_listutxos_response(utxos) |
||||
return make_jmwalletd_response(request, utxos=utxos_response) |
||||
|
||||
#route to start a coinjoin transaction |
||||
@app.route('/wallet/<string:walletname>/taker/coinjoin',methods=['POST']) |
||||
def docoinjoin(self, request, walletname): |
||||
self.check_cookie(request) |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
if not self.wallet_name == walletname: |
||||
raise InvalidRequestFormat() |
||||
request_data = self.get_POST_body(request,["mixdepth", "amount_sats", |
||||
"counterparties", "destination"]) |
||||
if not request_data: |
||||
raise InvalidRequestFormat() |
||||
#see file scripts/sample-schedule-for-testnet for schedule format |
||||
waittime = 0 |
||||
rounding= 16 |
||||
completion_flag= 0 |
||||
# A schedule is a list of lists, here we have only one item |
||||
try: |
||||
schedule = [[int(request_data["mixdepth"]), |
||||
int(request_data["amount_sats"]), |
||||
int(request_data["counterparties"]), |
||||
request_data["destination"], waittime, |
||||
rounding, completion_flag]] |
||||
except ValueError: |
||||
raise InvalidRequestFormat() |
||||
# Before actual start, update our coinjoin state: |
||||
if not self.activate_coinjoin_state(CJ_TAKER_RUNNING): |
||||
raise ServiceAlreadyStarted() |
||||
# Instantiate a Taker. |
||||
# `order_chooser` is whatever is default for Taker. |
||||
# max_cj_fee is to be set based on config values. |
||||
# If user has not set config, we only for now raise |
||||
# an error specific to this case; in future we can |
||||
# pass a request to a client to set the values, as |
||||
# we do in CLI (the usual reasoning applies as to |
||||
# why no defaults). |
||||
def dummy_user_callback(rel, abs): |
||||
raise ConfigNotPresent() |
||||
max_cj_fee= get_max_cj_fee_values(jm_single().config, |
||||
None, user_callback=dummy_user_callback) |
||||
self.taker = Taker(self.wallet_service, schedule, |
||||
max_cj_fee = max_cj_fee, |
||||
callbacks=(self.filter_orders_callback, |
||||
None, self.taker_finished)) |
||||
# TODO ; this makes use of a pre-existing hack to allow |
||||
# selectively disabling the stallMonitor function that checks |
||||
# if transactions went through or not; here we want to cleanly |
||||
# destroy the Taker after an attempt is made, successful or not. |
||||
self.taker.testflag = True |
||||
self.clientfactory = JMClientProtocolFactory(self.taker) |
||||
|
||||
dhost, dport = self.check_daemon_ready() |
||||
|
||||
_, self.coinjoin_connection = start_reactor(dhost, dport, |
||||
self.clientfactory, rs=False) |
||||
return make_jmwalletd_response(request) |
||||
@ -0,0 +1,84 @@
|
||||
import json |
||||
from autobahn.twisted.websocket import WebSocketServerFactory, \ |
||||
WebSocketServerProtocol |
||||
from jmbitcoin import human_readable_transaction |
||||
from jmbase import get_log |
||||
|
||||
jlog = get_log() |
||||
|
||||
class JmwalletdWebSocketServerProtocol(WebSocketServerProtocol): |
||||
def onOpen(self): |
||||
self.token = None |
||||
self.factory.register(self) |
||||
|
||||
def sendNotification(self, info): |
||||
""" Passes on an object (json encoded) to the client, |
||||
if currently authenticated. |
||||
""" |
||||
if not self.token: |
||||
# gating by token means even if this client |
||||
# is erroneously in a broadcast list, it won't get |
||||
# any data if it hasn't authenticated. |
||||
jlog.warn("Websocket not sending notification, " |
||||
"the connection is not authenticated.") |
||||
return |
||||
self.sendMessage(json.dumps(info).encode()) |
||||
|
||||
def connectionLost(self, reason): |
||||
""" Overridden to ensure that we aren't attempting to |
||||
send notifications on broken connections. |
||||
""" |
||||
WebSocketServerProtocol.connectionLost(self, reason) |
||||
self.factory.unregister(self) |
||||
|
||||
def onMessage(self, payload, isBinary): |
||||
""" We currently only allow messages which |
||||
are JWT tokens used for authentication. Any |
||||
other message will drop the connection. |
||||
""" |
||||
if not isBinary: |
||||
self.token = payload.decode('utf8') |
||||
# check that the token set for this protocol |
||||
# instance is the same as the one that the |
||||
# JMWalletDaemon instance deems is valid. |
||||
if not self.factory.check_token(self.token): |
||||
self.dropConnection() |
||||
|
||||
class JmwalletdWebSocketServerFactory(WebSocketServerFactory): |
||||
def __init__(self, url): |
||||
WebSocketServerFactory.__init__(self, url) |
||||
self.valid_token = None |
||||
self.clients = [] |
||||
|
||||
def check_token(self, token): |
||||
return self.valid_token == token |
||||
|
||||
def register(self, client): |
||||
if client not in self.clients: |
||||
self.clients.append(client) |
||||
|
||||
def unregister(self, client): |
||||
if client in self.clients: |
||||
self.clients.remove(client) |
||||
|
||||
def sendTxNotification(self, txd, txid): |
||||
""" Note that this is a WalletService callback; |
||||
the return value is only important for conf/unconf |
||||
callbacks, not for 'all' callbacks, so we return |
||||
None |
||||
""" |
||||
json_tx = json.loads(human_readable_transaction(txd)) |
||||
for client in self.clients: |
||||
client.sendNotification({"txid": txid, |
||||
"txdetails": json_tx}) |
||||
|
||||
def sendCoinjoinStatusUpdate(self, new_state): |
||||
""" The state sent is an integer, see |
||||
jmclient.wallet_rpc. |
||||
0: taker is running |
||||
1: maker is running (but not necessarily currently |
||||
coinjoining, note) |
||||
2: neither is running |
||||
""" |
||||
for client in self.clients: |
||||
client.sendNotification({"coinjoin_state": new_state}) |
||||
@ -0,0 +1,411 @@
|
||||
import os, json |
||||
from twisted.internet import reactor, defer, task |
||||
|
||||
from twisted.web.client import readBody, Headers |
||||
from twisted.trial import unittest |
||||
|
||||
from autobahn.twisted.websocket import WebSocketClientFactory, \ |
||||
connectWS |
||||
|
||||
from jmbase import get_nontor_agent, hextobin, BytesProducer, get_log |
||||
from jmbitcoin import CTransaction |
||||
from jmclient import (load_test_config, jm_single, |
||||
JMWalletDaemon, validate_address, start_reactor) |
||||
from jmclient.wallet_rpc import api_version_string |
||||
from commontest import make_wallets |
||||
from test_coinjoin import make_wallets_to_list, sync_wallets |
||||
|
||||
from test_websocket import (ClientTProtocol, test_tx_hex_1, |
||||
test_tx_hex_txid, encoded_token) |
||||
|
||||
testdir = os.path.dirname(os.path.realpath(__file__)) |
||||
|
||||
testfileloc = "testwrpc.jmdat" |
||||
|
||||
jlog = get_log() |
||||
|
||||
class JMWalletDaemonT(JMWalletDaemon): |
||||
def check_cookie(self, request): |
||||
if self.auth_disabled: |
||||
return True |
||||
return super().check_cookie(request) |
||||
|
||||
class WalletRPCTestBase(object): |
||||
""" Base class for set up of tests of the |
||||
Wallet RPC calls using the wallet_rpc.JMWalletDaemon service. |
||||
""" |
||||
# the indices in our wallets to populate |
||||
wallet_structure = [1, 3, 0, 0, 0] |
||||
# the mean amount of each deposit in the above indices, in btc |
||||
mean_amt = 2.0 |
||||
# the port for the jmwallet daemon |
||||
dport = 28183 |
||||
# the port for the ws |
||||
wss_port = 28283 |
||||
|
||||
def setUp(self): |
||||
load_test_config() |
||||
self.clean_out_wallet_file() |
||||
jm_single().bc_interface.tick_forward_chain_interval = 5 |
||||
jm_single().bc_interface.simulate_blocks() |
||||
# a client connnection object which is often but not always |
||||
# instantiated: |
||||
self.client_connector = None |
||||
# start the daemon; note we are using tcp connections |
||||
# to avoid storing certs in the test env. |
||||
# TODO change that. |
||||
self.daemon = JMWalletDaemonT(self.dport, self.wss_port, tls=False) |
||||
self.daemon.auth_disabled = False |
||||
# because we sync and start the wallet service manually here |
||||
# (and don't use wallet files yet), we won't have set a wallet name, |
||||
# so we set it here: |
||||
self.daemon.wallet_name = testfileloc |
||||
r, s = self.daemon.startService() |
||||
self.listener_rpc = r |
||||
self.listener_ws = s |
||||
wallet_structures = [self.wallet_structure] * 2 |
||||
# note: to test fidelity bond wallets we should add the argument |
||||
# `wallet_cls=SegwitWalletFidelityBonds` here, but it slows the |
||||
# test down from 9 seconds to 1 minute 40s, which is too slow |
||||
# to be acceptable. TODO: add a test with FB by speeding up |
||||
# the sync for test, by some means or other. |
||||
self.daemon.wallet_service = make_wallets_to_list(make_wallets( |
||||
1, wallet_structures=[wallet_structures[0]], |
||||
mean_amt=self.mean_amt))[0] |
||||
jm_single().bc_interface.tickchain() |
||||
sync_wallets([self.daemon.wallet_service]) |
||||
# dummy tx example to force a notification event: |
||||
self.test_tx = CTransaction.deserialize(hextobin(test_tx_hex_1)) |
||||
|
||||
def get_route_root(self): |
||||
addr = "http://127.0.0.1:" + str(self.dport) |
||||
addr += api_version_string |
||||
return addr |
||||
|
||||
def clean_out_wallet_file(self): |
||||
if os.path.exists(os.path.join(".", "wallets", testfileloc)): |
||||
os.remove(os.path.join(".", "wallets", testfileloc)) |
||||
|
||||
def tearDown(self): |
||||
self.clean_out_wallet_file() |
||||
for dc in reactor.getDelayedCalls(): |
||||
dc.cancel() |
||||
d1 = defer.maybeDeferred(self.listener_ws.stopListening) |
||||
d2 = defer.maybeDeferred(self.listener_rpc.stopListening) |
||||
if self.client_connector: |
||||
self.client_connector.disconnect() |
||||
# only fire if everything is finished: |
||||
return defer.gatherResults([d1, d2]) |
||||
|
||||
class TrialTestWRPC_WS(WalletRPCTestBase, unittest.TestCase): |
||||
""" class for testing websocket subscriptions/events etc. |
||||
""" |
||||
def test_notif(self): |
||||
# simulate the daemon already having created |
||||
# a valid token (which it usually does when |
||||
# starting the WalletService: |
||||
self.daemon.wss_factory.valid_token = encoded_token |
||||
self.client_factory = WebSocketClientFactory( |
||||
"ws://127.0.0.1:"+str(self.wss_port)) |
||||
self.client_factory.protocol = ClientTProtocol |
||||
self.client_connector = connectWS(self.client_factory) |
||||
d = task.deferLater(reactor, 0.1, self.fire_tx_notif) |
||||
# create a small delay between the instruction to send |
||||
# the notification, and the checking of its receipt, |
||||
# otherwise the client will be queried before the notification |
||||
# arrived: |
||||
d.addCallback(self.wait_to_receive) |
||||
return d |
||||
|
||||
def wait_to_receive(self, res): |
||||
d = task.deferLater(reactor, 0.1, self.checkNotifs) |
||||
return d |
||||
|
||||
def checkNotifs(self): |
||||
assert self.client_factory.notifs == 1 |
||||
|
||||
def fire_tx_notif(self): |
||||
self.daemon.wss_factory.sendTxNotification(self.test_tx, |
||||
test_tx_hex_txid) |
||||
|
||||
class TrialTestWRPC_DisplayWallet(WalletRPCTestBase, unittest.TestCase): |
||||
|
||||
@defer.inlineCallbacks |
||||
def do_request(self, agent, method, addr, body, handler, token=None): |
||||
if token: |
||||
headers = Headers({"Authorization": ["Bearer " + self.jwt_token]}) |
||||
else: |
||||
headers = None |
||||
response = yield agent.request(method, addr, headers, bodyProducer=body) |
||||
yield self.response_handler(response, handler) |
||||
|
||||
@defer.inlineCallbacks |
||||
def response_handler(self, response, handler): |
||||
body = yield readBody(response) |
||||
# these responses should always be 200 OK. |
||||
assert response.code == 200 |
||||
# handlers check the body is as expected; no return. |
||||
yield handler(body) |
||||
return True |
||||
|
||||
@defer.inlineCallbacks |
||||
def test_create_list_lock_unlock(self): |
||||
""" A batch of tests in sequence here, |
||||
so we can track the state of a created |
||||
wallet and check it is what is expected. |
||||
We test create first, so we have a wallet. |
||||
|
||||
1. create a wallet and have it persisted |
||||
to disk in ./wallets, and get a token. |
||||
2. list wallets and check they contain the new |
||||
wallet. |
||||
3. lock the existing wallet service, using the token. |
||||
4. Unlock the wallet with /unlock, get a token. |
||||
""" |
||||
# before starting, we have to shut down the existing |
||||
# wallet service (usually this would be `lock`): |
||||
self.daemon.wallet_service = None |
||||
self.daemon.stopService() |
||||
self.daemon.auth_disabled = False |
||||
|
||||
agent = get_nontor_agent() |
||||
root = self.get_route_root() |
||||
addr = root + "/wallet/create" |
||||
addr = addr.encode() |
||||
body = BytesProducer(json.dumps({"walletname": testfileloc, |
||||
"password": "hunter2", "wallettype": "sw"}).encode()) |
||||
yield self.do_request(agent, b"POST", addr, body, |
||||
self.process_create_wallet_response) |
||||
|
||||
addr = root + "/wallet/all" |
||||
addr = addr.encode() |
||||
# does not require a token, though we just got one. |
||||
yield self.do_request(agent, b"GET", addr, None, |
||||
self.process_list_wallets_response) |
||||
|
||||
# now *lock* the existing, which will shut down the wallet |
||||
# service associated. |
||||
addr = root + "/wallet/" + self.daemon.wallet_name + "/lock" |
||||
addr = addr.encode() |
||||
jlog.info("Using address: {}".format(addr)) |
||||
yield self.do_request(agent, b"GET", addr, None, |
||||
self.process_lock_response, token=self.jwt_token) |
||||
# wallet service should now be stopped. |
||||
addr = root + "/wallet/" + self.daemon.wallet_name + "/unlock" |
||||
addr = addr.encode() |
||||
body = BytesProducer(json.dumps({"password": "hunter2"}).encode()) |
||||
yield self.do_request(agent, b"POST", addr, body, |
||||
self.process_unlock_response) |
||||
|
||||
|
||||
def process_create_wallet_response(self, response): |
||||
json_body = json.loads(response.decode("utf-8")) |
||||
assert json_body["walletname"] == testfileloc |
||||
self.jwt_token = json_body["token"] |
||||
# we don't use this in test, but it must exist: |
||||
assert json_body["seedphrase"] |
||||
|
||||
def process_list_wallets_response(self, body): |
||||
json_body = json.loads(body.decode("utf-8")) |
||||
assert json_body["wallets"] == [testfileloc] |
||||
|
||||
@defer.inlineCallbacks |
||||
def test_direct_send_and_display_wallet(self): |
||||
""" First spend a coin, then check the balance |
||||
via the display wallet output. |
||||
""" |
||||
self.daemon.auth_disabled = True |
||||
agent = get_nontor_agent() |
||||
addr = self.get_route_root() |
||||
addr += "/wallet/" |
||||
addr += self.daemon.wallet_name |
||||
addr += "/taker/direct-send" |
||||
addr = addr.encode() |
||||
body = BytesProducer(json.dumps({"mixdepth": "1", |
||||
"amount_sats": "100000000", |
||||
"destination": "2N2JD6wb56AfK4tfmM6PwdVmoYk2dCKf4Br"}).encode()) |
||||
yield self.do_request(agent, b"POST", addr, body, |
||||
self.process_direct_send_response) |
||||
# force the wallet service txmonitor to wake up, to see the new |
||||
# tx before querying /display: |
||||
self.daemon.wallet_service.transaction_monitor() |
||||
addr = self.get_route_root() |
||||
addr += "/wallet/" |
||||
addr += self.daemon.wallet_name |
||||
addr += "/display" |
||||
addr = addr.encode() |
||||
yield self.do_request(agent, b"GET", addr, None, |
||||
self.process_wallet_display_response) |
||||
|
||||
def process_direct_send_response(self, response): |
||||
json_body = json.loads(response.decode("utf-8")) |
||||
assert "txinfo" in json_body |
||||
# TODO tx check |
||||
print(json_body["txinfo"]) |
||||
|
||||
def process_wallet_display_response(self, response): |
||||
json_body = json.loads(response.decode("utf-8")) |
||||
latest_balance = float(json_body["walletinfo"]["total_balance"]) |
||||
jlog.info("Wallet display currently shows balance: {}".format( |
||||
latest_balance)) |
||||
assert latest_balance > self.mean_amt * 4.0 - 1.1 |
||||
assert latest_balance <= self.mean_amt * 4.0 - 1.0 |
||||
|
||||
@defer.inlineCallbacks |
||||
def test_getaddress(self): |
||||
""" Tests that we can source a valid address |
||||
for deposits using getaddress. |
||||
""" |
||||
self.daemon.auth_disabled = True |
||||
agent = get_nontor_agent() |
||||
addr = self.get_route_root() |
||||
addr += "/wallet/" |
||||
addr += self.daemon.wallet_name |
||||
addr += "/address/new/3" |
||||
addr = addr.encode() |
||||
yield self.do_request(agent, b"GET", addr, None, |
||||
self.process_new_addr_response) |
||||
|
||||
def process_new_addr_response(self, response): |
||||
json_body = json.loads(response.decode("utf-8")) |
||||
assert validate_address(json_body["address"])[0] |
||||
|
||||
@defer.inlineCallbacks |
||||
def test_listutxos(self): |
||||
self.daemon.auth_disabled = True |
||||
agent = get_nontor_agent() |
||||
addr = self.get_route_root() |
||||
addr += "/wallet/" |
||||
addr += self.daemon.wallet_name |
||||
addr += "/utxos" |
||||
addr = addr.encode() |
||||
yield self.do_request(agent, b"GET", addr, None, |
||||
self.process_listutxos_response) |
||||
|
||||
def process_listutxos_response(self, response): |
||||
json_body = json.loads(response.decode("utf-8")) |
||||
# some fragility in test structure here: what utxos we |
||||
# have depend on what other tests occurred. |
||||
# For now, we at least check that we have 3 utxos in mixdepth |
||||
# 1 because none of the other tests spend them: |
||||
mixdepth1_utxos = 0 |
||||
for d in json_body["utxos"]: |
||||
if d["mixdepth"] == 1: |
||||
mixdepth1_utxos += 1 |
||||
assert mixdepth1_utxos == 3 |
||||
|
||||
@defer.inlineCallbacks |
||||
def test_session(self): |
||||
agent = get_nontor_agent() |
||||
addr = self.get_route_root() |
||||
addr += "/session" |
||||
addr = addr.encode() |
||||
yield self.do_request(agent, b"GET", addr, None, |
||||
self.process_session_response) |
||||
|
||||
def process_session_response(self, response): |
||||
json_body = json.loads(response.decode("utf-8")) |
||||
assert json_body["maker_running"] is False |
||||
assert json_body["coinjoin_in_process"] is False |
||||
|
||||
def process_unlock_response(self, response): |
||||
json_body = json.loads(response.decode("utf-8")) |
||||
assert json_body["walletname"] == testfileloc |
||||
self.jwt_token = json_body["token"] |
||||
|
||||
def process_lock_response(self, response): |
||||
json_body = json.loads(response.decode("utf-8")) |
||||
assert json_body["walletname"] == testfileloc |
||||
|
||||
@defer.inlineCallbacks |
||||
def test_do_coinjoin(self): |
||||
""" This slightly weird test curently only |
||||
tests *requesting* a coinjoin; because there are |
||||
no makers running in the test suite, the Taker will |
||||
give up early due to the empty orderbook, but that is |
||||
OK since this API call only makes the request. |
||||
""" |
||||
self.daemon.auth_disabled = True |
||||
# in normal operations, the RPC call will trigger |
||||
# the jmclient to connect to an *existing* daemon |
||||
# that was created on startup, but here, that daemon |
||||
# does not yet exist, so we will get 503 Backend Not Ready, |
||||
# unless we manually create it: |
||||
scon, ccon = start_reactor(jm_single().config.get("DAEMON", |
||||
"daemon_host"), jm_single().config.getint("DAEMON", |
||||
"daemon_port"), None, daemon=True, rs=False) |
||||
# must be manually set: |
||||
self.scon = scon |
||||
agent = get_nontor_agent() |
||||
addr = self.get_route_root() |
||||
addr += "/wallet/" |
||||
addr += self.daemon.wallet_name |
||||
addr += "/taker/coinjoin" |
||||
addr = addr.encode() |
||||
body = BytesProducer(json.dumps({"mixdepth": "1", |
||||
"amount_sats": "22000000", |
||||
"counterparties": "2", |
||||
"destination": "2N2JD6wb56AfK4tfmM6PwdVmoYk2dCKf4Br"}).encode()) |
||||
yield self.do_request(agent, b"POST", addr, body, |
||||
self.process_do_coinjoin_response) |
||||
|
||||
def process_do_coinjoin_response(self, response): |
||||
# response code is already checked to be 200 |
||||
clientconn = self.daemon.coinjoin_connection |
||||
# backend's AMP connection must be cleaned up, otherwise |
||||
# test will fail for unclean reactor: |
||||
self.addCleanup(clientconn.disconnect) |
||||
self.addCleanup(self.scon.stopListening) |
||||
assert json.loads(response.decode("utf-8")) == {} |
||||
""" |
||||
Sample listutxos response for reference: |
||||
|
||||
{ |
||||
"utxos": [{ |
||||
"utxo": "e01f349b1b5659c01f09ec70ca418a26d34f573e13f878db46dff39763e4dd15:0", |
||||
"address": "bcrt1qxgqw54x46kmkkg6g23kdfuy76mfhc4m88shg4n", |
||||
"value": 200000000, |
||||
"tries": 0, |
||||
"tries_remaining": 3, |
||||
"external": false, |
||||
"mixdepth": 0, |
||||
"confirmations": 5, |
||||
"frozen": false |
||||
}, { |
||||
"utxo": "eba94a0011e0f3f97a9c49be7f6ae38eb75bbeacd8c1797425e9005d80ec2f70:0", |
||||
"address": "bcrt1qz5p304dj54g9nxh87afyvwpkv0jd3lydka6nfp", |
||||
"value": 200000000, |
||||
"tries": 0, |
||||
"tries_remaining": 3, |
||||
"external": false, |
||||
"mixdepth": 1, |
||||
"confirmations": 4, |
||||
"frozen": false |
||||
}, { |
||||
"utxo": "fd5f181f1c1d1d47f3f110c3426769e60450e779addabf3f57f1732099ecdf97:0", |
||||
"address": "bcrt1qu7k4dppungsqp95nwc7ansqs9m0z95h72j9mze", |
||||
"value": 200000000, |
||||
"tries": 0, |
||||
"tries_remaining": 3, |
||||
"external": false, |
||||
"mixdepth": 1, |
||||
"confirmations": 3, |
||||
"frozen": false |
||||
}, { |
||||
"utxo": "03de36659e18068d272e182b2a57fdf8364d0d8c9aaf1b8c971a1590fa983cd5:0", |
||||
"address": "bcrt1qk0thvwz8djvnynv2cmq7706ff9tjxcjef3cr7l", |
||||
"value": 200000000, |
||||
"tries": 0, |
||||
"tries_remaining": 3, |
||||
"external": false, |
||||
"mixdepth": 1, |
||||
"confirmations": 2, |
||||
"frozen": false |
||||
}] |
||||
} |
||||
""" |
||||
|
||||
""" |
||||
Sample displaywallet response for reference: |
||||
[{"succeed": true, "status": 200, "walletname": "testwrpc.jmdat", "walletinfo": {"wallet_name": "JM wallet", "total_balance": "6.99998570", "accounts": [{"account": "0", "account_balance": "2.00000000", "branches": [{"branch": "external addresses\tm/84'/1'/0'/0\ttpubDExGchYUujKhNNYvVMjW6S9X4B3Cd3mNqm19vknwovH8buM7GJACi6gCi8Qc9Q9ejBx7phVRUrJFNT5GwpcUSTLqEKNbdCEaKLMdKfgp6Yd", "balance": "2.00000000", "entries": [{"hd_path": "m/84'/1'/0'/0/0", "address": "bcrt1qk4txxx2xzdz8y6yg2w60l9lea6h3k3el7jqnxk", "amount": "2.00000000", "labels": "used"}]}, {"branch": "internal addresses\tm/84'/1'/0'/1\t", "balance": "0.00000000", "entries": []}]}, {"account": "1", "account_balance": "4.99998570", "branches": [{"branch": "external addresses\tm/84'/1'/1'/0\ttpubDET2QAFuGCcmMhzJ6E7yTKUD5Fc8PqnL81yxmb2YZuWcG2MmhoUjLERK7S2gwyGPM1wiaCxWRjWXjnw3KgC9X2wMN38YRj3z4yz43HoMP67", "balance": "4.00000000", "entries": [{"hd_path": "m/84'/1'/1'/0/0", "address": "bcrt1qyqa9sawgwmkpy3pg599mv6peyg9uag8s2pdkpr", "amount": "2.00000000", "labels": "used"}, {"hd_path": "m/84'/1'/1'/0/1", "address": "bcrt1q0ky7pwdzpftd3jy6w6rt8krap2tsrcuzjte69y", "amount": "2.00000000", "labels": "used"}]}, {"branch": "internal addresses\tm/84'/1'/1'/1\t", "balance": "0.99998570", "entries": [{"hd_path": "m/84'/1'/1'/1/0", "address": "bcrt1qjdnnz5w75upqquvcsksyyeq0u9c2m5j9eld0nf", "amount": "0.99998570", "labels": "used"}]}]}, {"account": "2", "account_balance": "0.00000000", "branches": [{"branch": "external addresses\tm/84'/1'/2'/0\ttpubDEGRBmiDr2tqdcQFCVykULPzmuvTUeXCrG6w7C46wp7wrncU1hPpSzoYKn44kw6J6i5doWLSx8bzkjBeh8HvqRVPzJBetuq5xeV2iFWwS6q", "balance": "0.00000000", "entries": []}, {"branch": "internal addresses\tm/84'/1'/2'/1\t", "balance": "0.00000000", "entries": []}]}, {"account": "3", "account_balance": "0.00000000", "branches": [{"branch": "external addresses\tm/84'/1'/3'/0\ttpubDFa44cU854x2qYsHgWU1CFNaNRyQwaceXEHb41BEWw97KMmpaWP9JrbdF3mnzCq1se8GbnT5Ra7erPrh8vSCCNqPUsmsahYVZ3dgVg19dWF", "balance": "0.00000000", "entries": []}, {"branch": "internal addresses\tm/84'/1'/3'/1\t", "balance": "0.00000000", "entries": []}]}, {"account": "4", "account_balance": "0.00000000", "branches": [{"branch": "external addresses\tm/84'/1'/4'/0\ttpubDFK8hTjQBCEz3aaiDeyucPX56DBZprCpJZ5Jrb2cHiWDTudBTYtj6EHSxXypnQQFPAfJH6zVVnC6YzeHBsc79XErY1AkQrJkayySMhKhQbK", "balance": "0.00000000", "entries": []}, {"branch": "internal addresses\tm/84'/1'/4'/1\t", "balance": "0.00000000", "entries": []}]}]}}] |
||||
""" |
||||
@ -0,0 +1,109 @@
|
||||
|
||||
import os |
||||
import json |
||||
import datetime |
||||
from twisted.internet import reactor, task |
||||
from twisted.trial import unittest |
||||
from autobahn.twisted.websocket import WebSocketClientFactory, \ |
||||
WebSocketClientProtocol, connectWS, listenWS |
||||
import jwt |
||||
|
||||
from jmbase import get_log, hextobin |
||||
from jmclient import (JmwalletdWebSocketServerFactory, |
||||
JmwalletdWebSocketServerProtocol) |
||||
from jmbitcoin import CTransaction |
||||
|
||||
testdir = os.path.dirname(os.path.realpath(__file__)) |
||||
jlog = get_log() |
||||
|
||||
# example transaction for sending a notification with: |
||||
test_tx_hex_1 = "02000000000102578770b2732aed421ffe62d54fd695cf281ca336e4f686d2adbb2e8c3bedb2570000000000ffffffff4719a259786b4237f92460629181edcc3424419592529103143090f07d85ec330100000000ffffffff0324fd9b0100000000160014d38fa4a6ac8db7495e5e2b5d219dccd412dd9bae24fd9b0100000000160014564aead56de8f4d445fc5b74a61793b5c8a819667af6c208000000001600146ec55c2e1d1a7a868b5ec91822bf40bba842bac502473044022078f8106a5645cc4afeef36d4addec391a5b058cc51053b42c89fcedf92f4db1002200cdf1b66a922863fba8dc1b1b1a0dce043d952fa14dcbe86c427fda25e930a53012102f1f750bfb73dbe4c7faec2c9c301ad0e02176cd47bcc909ff0a117e95b2aad7b02483045022100b9a6c2295a1b0f7605381d416f6ed8da763bd7c20f2402dd36b62dd9dd07375002207d40eaff4fc6ee219a7498abfab6bdc54b7ce006ac4b978b64bff960fbf5f31e012103c2a7d6e44acdbd503c578ec7d1741a44864780be0186e555e853eee86e06f11f00000000" |
||||
test_tx_hex_txid = "ca606efc5ba8f6669ba15e9262e5d38e745345ea96106d5a919688d1ff0da0cc" |
||||
|
||||
# example (valid) JWT token for test: |
||||
encoded_token = jwt.encode({"wallet": "dummywallet", |
||||
"exp" :datetime.datetime.utcnow( |
||||
)+datetime.timedelta(minutes=30)}, "secret") |
||||
encoded_token = encoded_token.strip() |
||||
|
||||
class ClientTProtocol(WebSocketClientProtocol): |
||||
""" |
||||
Simple client that connects to a WebSocket server, send a HELLO |
||||
message every 2 seconds and print everything it receives. |
||||
""" |
||||
|
||||
def sendAuth(self): |
||||
""" Our server will not broadcast |
||||
to us unless we authenticate. |
||||
""" |
||||
self.sendMessage(encoded_token.encode('utf8')) |
||||
|
||||
def onOpen(self): |
||||
# auth on startup |
||||
self.sendAuth() |
||||
# for test, monitor how many times we |
||||
# were notified. |
||||
self.factory.notifs = 0 |
||||
|
||||
def onMessage(self, payload, isBinary): |
||||
if not isBinary: |
||||
payload = payload.decode("utf-8") |
||||
jlog.info("Text message received: {}".format(payload)) |
||||
self.factory.notifs += 1 |
||||
# ensure we got the transaction message expected: |
||||
deser_notif = json.loads(payload) |
||||
assert deser_notif["txid"] == test_tx_hex_txid |
||||
assert deser_notif["txdetails"]["txid"] == test_tx_hex_txid |
||||
|
||||
|
||||
class WebsocketTestBase(object): |
||||
""" This tests that a websocket client can connect to our |
||||
websocket subscription service |
||||
""" |
||||
# the port for the ws |
||||
wss_port = 28283 |
||||
|
||||
def setUp(self): |
||||
self.wss_url = "ws://127.0.0.1:" + str(self.wss_port) |
||||
self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url) |
||||
self.wss_factory.protocol = JmwalletdWebSocketServerProtocol |
||||
self.wss_factory.valid_token = encoded_token |
||||
self.listeningport = listenWS(self.wss_factory, contextFactory=None) |
||||
self.test_tx = CTransaction.deserialize(hextobin(test_tx_hex_1)) |
||||
|
||||
def stopListening(self): |
||||
return self.listeningport.stopListening() |
||||
|
||||
def do_test(self): |
||||
self.client_factory = WebSocketClientFactory("ws://127.0.0.1:"+str(self.wss_port)) |
||||
self.client_factory.protocol = ClientTProtocol |
||||
# keep track of the connector object so we can close it manually: |
||||
self.client_connector = connectWS(self.client_factory) |
||||
d = task.deferLater(reactor, 0.1, self.fire_tx_notif) |
||||
# create a small delay between the instruction to send |
||||
# the notification, and the checking of its receipt, |
||||
# otherwise the client will be queried before the notification |
||||
# arrived: |
||||
d.addCallback(self.wait_to_receive) |
||||
return d |
||||
|
||||
def wait_to_receive(self, res): |
||||
d = task.deferLater(reactor, 0.1, self.checkNotifs) |
||||
return d |
||||
|
||||
def checkNotifs(self): |
||||
assert self.client_factory.notifs == 1 |
||||
|
||||
def fire_tx_notif(self): |
||||
self.wss_factory.sendTxNotification(self.test_tx, |
||||
test_tx_hex_txid) |
||||
|
||||
def tearDown(self): |
||||
for dc in reactor.getDelayedCalls(): |
||||
dc.cancel() |
||||
self.client_connector.disconnect() |
||||
return self.stopListening() |
||||
|
||||
class TrialTestWS(WebsocketTestBase, unittest.TestCase): |
||||
def test_basic_notification(self): |
||||
return self.do_test() |
||||
Loading…
Reference in new issue