From cbf32a82fbc9932fae3f7630698c38722b2d6d34 Mon Sep 17 00:00:00 2001
From: woodser 
Date: Fri, 17 Sep 2021 09:33:58 -0400
Subject: [PATCH] test that offers filtered when reserved funds spent or
 duplicated
introduce envoy config for running tests with alice and bob
test offer removal when reserved output spent using monero-javascript
add instructions to run tests in readme
update protobuf definitions
---
 README.md                 |  23 +++-
 config/envoy.test.yaml    | 104 ++++++++++++++++
 config/envoy.yaml         |   8 +-
 config/pb.proto           |   4 +-
 package-lock.json         | 243 ++++++++++++++++++++++++++++++++++++++
 package.json              |   3 +
 src/HavenoDaemon.test.tsx | 175 +++++++++++++++++++++------
 src/HavenoDaemon.tsx      |   4 +-
 src/protobuf/pb_pb.d.ts   |  12 ++
 src/protobuf/pb_pb.js     | 121 +++++++++++++++++--
 10 files changed, 647 insertions(+), 50 deletions(-)
 create mode 100644 config/envoy.test.yaml
diff --git a/README.md b/README.md
index a6866ab3..e113a7d8 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,11 @@
 
 A proof of concept to fetch and render data from Haveno's daemon in ReactJS.
 
-This application is a basic [create-react-app](https://github.com/facebook/create-react-app) with typescript using [grpc-web](https://github.com/grpc/grpc-web) and a proxy ([envoy](https://www.envoyproxy.io/)) for Haveno's gRPC daemon.
+This application is a lightly modified [create-react-app](https://github.com/facebook/create-react-app) with typescript using [envoy proxy](https://www.envoyproxy.io/) and [grpc-web](https://github.com/grpc/grpc-web) to use Haveno's gRPC API.
 
-## How to Run in a Browser
+## Run in a Browser
 
-1. [Run a local Haveno test network](https://github.com/woodser/haveno#running-a-local-haveno-test-network) except replace `./haveno-desktop` with `./haveno-daemon` when starting Alice at port 9999.
+1. [Run a local Haveno test network](https://github.com/haveno-dex/haveno/blob/master/docs/installing.md) except replace `./haveno-desktop` with `./haveno-daemon` when starting Alice at port 9999.
 2. `git clone https://github.com/haveno-dex/haveno-ui-poc`
 4. Start envoy with the config in ./config/envoy.yaml
   Example: `docker run --rm -it -v ~/git/haveno-ui-poc/config/envoy.yaml:/envoy.yaml -p 8080:8080 envoyproxy/envoy-dev:8a2143613d43d17d1eb35a24b4a4a4c432215606 -c /envoy.yaml`
@@ -18,8 +18,25 @@ This application is a basic [create-react-app](https://github.com/facebook/creat
     
 
 
+## Run Tests
+
+Running the [top-level API tests](./src/HavenoDaemon.test.tsx) is a great way to develop and test Haveno end-to-end.
+
+[`HavenoDaemon`](./src/HavenoDaemon.tsx) provides the interface to the Haveno daemon's gRPC API.
+
+1. [Run a local Haveno test network](https://github.com/haveno-dex/haveno/blob/master/docs/installing.md) except replace `./haveno-desktop` with `./haveno-daemon` when starting Alice at port 9999 and Bob at port 10000.
+2. `git clone https://github.com/haveno-dex/haveno-ui-poc`
+4. Start envoy with the test config in ./config/envoy.test.yaml.
+  Example: `docker run --rm -it -v ~/git/haveno-ui-poc/config/envoy.test.yaml:/envoy.test.yaml -p 8080:8080 -p 8081:8081 envoyproxy/envoy-dev:8a2143613d43d17d1eb35a24b4a4a4c432215606 -c /envoy.test.yaml`
+5. `npm install`
+6. Modify test config as needed in [HavenoDaemon.test.tsx](./src/HavenoDaemon.test.tsx).
+7. `npm test`
+8. Run all tests: `a`
+
 ## How to Update the Protobuf Client
 
+If the protobuf definitions in haveno-dex/haveno are updated, the typescript imports must be regenerated:
+
 1. Copy grpc.proto and pb.proto from Haveno's [protobuf definitions](https://github.com/haveno-dex/haveno/tree/master/proto/src/main/proto) to ./config.
 2. Install protobuf for your system, e.g. on mac: `brew install protobuf`
 3. `./bin/build_protobuf.sh`
\ No newline at end of file
diff --git a/config/envoy.test.yaml b/config/envoy.test.yaml
new file mode 100644
index 00000000..7c946c87
--- /dev/null
+++ b/config/envoy.test.yaml
@@ -0,0 +1,104 @@
+# envoy configuration to test with alice and bob trader instances
+
+admin:
+  access_log_path: /tmp/admin_access.log
+  address:
+    socket_address: { address: 0.0.0.0, port_value: 9901 }
+
+static_resources:
+  listeners:
+  - name: alice_listener
+    address:
+      socket_address: { address: 0.0.0.0, port_value: 8080 }
+    filter_chains:
+    - filters:
+      - name: envoy.filters.network.http_connection_manager
+        typed_config:
+          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+          codec_type: auto
+          stat_prefix: ingress_http
+          route_config:
+            name: local_route
+            virtual_hosts:
+            - name: local_service
+              domains: ["*"]
+              routes:
+              - match: { prefix: "/" }
+                route:
+                  cluster: alice_service
+                  timeout: 0s
+                  max_stream_duration:
+                    grpc_timeout_header_max: 0s
+              cors:
+                allow_origin_string_match:
+                - prefix: "*"
+                allow_methods: GET, PUT, DELETE, POST, OPTIONS
+                allow_headers: password,keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
+                max_age: "1728000"
+                expose_headers: custom-header-1,grpc-status,grpc-message
+          http_filters:
+          - name: envoy.filters.http.grpc_web
+          - name: envoy.filters.http.cors
+          - name: envoy.filters.http.router
+  - name: bob_listener
+    address:
+      socket_address: { address: 0.0.0.0, port_value: 8081 }
+    filter_chains:
+    - filters:
+      - name: envoy.filters.network.http_connection_manager
+        typed_config:
+          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+          codec_type: auto
+          stat_prefix: ingress_http
+          route_config:
+            name: local_route
+            virtual_hosts:
+            - name: local_service
+              domains: ["*"]
+              routes:
+              - match: { prefix: "/" }
+                route:
+                  cluster: bob_service
+                  timeout: 0s
+                  max_stream_duration:
+                    grpc_timeout_header_max: 0s
+              cors:
+                allow_origin_string_match:
+                - prefix: "*"
+                allow_methods: GET, PUT, DELETE, POST, OPTIONS
+                allow_headers: password,keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
+                max_age: "1728000"
+                expose_headers: custom-header-1,grpc-status,grpc-message
+          http_filters:
+          - name: envoy.filters.http.grpc_web
+          - name: envoy.filters.http.cors
+          - name: envoy.filters.http.router
+  clusters:
+  - name: alice_service
+    connect_timeout: 0.25s
+    type: logical_dns
+    http2_protocol_options: {}
+    lb_policy: round_robin
+    load_assignment:
+      cluster_name: cluster_0
+      endpoints:
+        - lb_endpoints:
+            - endpoint:
+                address:
+                  socket_address:
+                    address: host.docker.internal
+                    port_value: 9999
+  - name: bob_service
+    connect_timeout: 0.25s
+    type: logical_dns
+    http2_protocol_options: {}
+    lb_policy: round_robin
+    load_assignment:
+      cluster_name: cluster_0
+      endpoints:
+        - lb_endpoints:
+            - endpoint:
+                address:
+                  socket_address:
+                    address: host.docker.internal
+                    port_value: 10000
\ No newline at end of file
diff --git a/config/envoy.yaml b/config/envoy.yaml
index 1d91375a..306c2a4a 100644
--- a/config/envoy.yaml
+++ b/config/envoy.yaml
@@ -1,3 +1,5 @@
+# envoy configuration to run alice ui
+
 admin:
   access_log_path: /tmp/admin_access.log
   address:
@@ -5,7 +7,7 @@ admin:
 
 static_resources:
   listeners:
-  - name: listener_0
+  - name: alice_listener
     address:
       socket_address: { address: 0.0.0.0, port_value: 8080 }
     filter_chains:
@@ -23,7 +25,7 @@ static_resources:
               routes:
               - match: { prefix: "/" }
                 route:
-                  cluster: haveno_service
+                  cluster: alice_service
                   timeout: 0s
                   max_stream_duration:
                     grpc_timeout_header_max: 0s
@@ -39,7 +41,7 @@ static_resources:
           - name: envoy.filters.http.cors
           - name: envoy.filters.http.router
   clusters:
-  - name: haveno_service
+  - name: alice_service
     connect_timeout: 0.25s
     type: logical_dns
     http2_protocol_options: {}
diff --git a/config/pb.proto b/config/pb.proto
index be245fcf..2ae1fa46 100644
--- a/config/pb.proto
+++ b/config/pb.proto
@@ -177,7 +177,8 @@ message SignOfferRequest {
     string reserve_tx_hash = 8;
     string reserve_tx_hex = 9;
     string reserve_tx_key = 10;
-    string payout_address = 11;
+    repeated string reserve_tx_key_images = 11;
+    string payout_address = 12;
 }
 
 message SignOfferResponse {
@@ -940,6 +941,7 @@ message OfferPayload {
     
     NodeAddress arbitrator_node_address = 1001;
     string arbitrator_signature = 1002;
+    repeated string reserve_tx_key_images = 1003;
 }
 
 message AccountAgeWitness {
diff --git a/package-lock.json b/package-lock.json
index a735f03a..bfdd1582 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,9 @@
         "react-scripts": "4.0.3",
         "typescript": "^4.2.4",
         "web-vitals": "^1.1.1"
+      },
+      "devDependencies": {
+        "monero-javascript": "^0.5.5"
       }
     },
     "node_modules/@babel/code-frame": {
@@ -5774,6 +5777,12 @@
         "node": "*"
       }
     },
+    "node_modules/crypto-js": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz",
+      "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==",
+      "dev": true
+    },
     "node_modules/crypto-random-string": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz",
@@ -9415,6 +9424,12 @@
         "node": ">=4.0.0"
       }
     },
+    "node_modules/html5-fs": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/html5-fs/-/html5-fs-0.1.1.tgz",
+      "integrity": "sha1-jxRcucLoOLt9D6kx4wraBUtHzX0=",
+      "dev": true
+    },
     "node_modules/htmlparser2": {
       "version": "3.10.1",
       "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
@@ -13170,6 +13185,58 @@
         "mkdirp": "bin/cmd.js"
       }
     },
+    "node_modules/monero-javascript": {
+      "version": "0.5.5",
+      "resolved": "https://registry.npmjs.org/monero-javascript/-/monero-javascript-0.5.5.tgz",
+      "integrity": "sha512-Fpm0kNxEwvQkHh46l4DEOQk/225og2ujQadeIFqQ1pk9DguIwL2wvLs2xiAgbA3lN+9hEWBdjl1N+UktEIE3Ag==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.12.6",
+        "async": "2.6.1",
+        "crypto-js": "^4.0.0",
+        "html5-fs": "0.1.1",
+        "net": "^1.0.2",
+        "promise-throttle": "^1.1.2",
+        "request": "^2.88.0",
+        "request-promise": "^4.2.6",
+        "serialize-javascript": "^3.1.0",
+        "text-encoding": "^0.7.0",
+        "tls": "0.0.1",
+        "uuid": "3.3.2",
+        "web-worker": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/monero-javascript/node_modules/async": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz",
+      "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==",
+      "dev": true,
+      "dependencies": {
+        "lodash": "^4.17.10"
+      }
+    },
+    "node_modules/monero-javascript/node_modules/serialize-javascript": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-3.1.0.tgz",
+      "integrity": "sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==",
+      "dev": true,
+      "dependencies": {
+        "randombytes": "^2.1.0"
+      }
+    },
+    "node_modules/monero-javascript/node_modules/uuid": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz",
+      "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==",
+      "deprecated": "Please upgrade  to version 7 or higher.  Older versions may use Math.random() in certain circumstances, which is known to be problematic.  See https://v8.dev/blog/math-random for details.",
+      "dev": true,
+      "bin": {
+        "uuid": "bin/uuid"
+      }
+    },
     "node_modules/move-concurrently": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@@ -13280,6 +13347,12 @@
       "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
       "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
     },
+    "node_modules/net": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/net/-/net-1.0.2.tgz",
+      "integrity": "sha1-0XV+yaf7I3HYPPR1XOPifhCCk4g=",
+      "dev": true
+    },
     "node_modules/next-tick": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
@@ -15630,6 +15703,12 @@
       "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
       "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM="
     },
+    "node_modules/promise-throttle": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/promise-throttle/-/promise-throttle-1.1.2.tgz",
+      "integrity": "sha512-dij7vjyXNewuuN/gyr+TX2KRjw48mbV5FEtgyXaIoJjGYAKT0au23/voNvy9eS4UNJjx2KUdEcO5Yyfc1h7vWQ==",
+      "dev": true
+    },
     "node_modules/prompts": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz",
@@ -16491,6 +16570,25 @@
         "node": ">= 6"
       }
     },
+    "node_modules/request-promise": {
+      "version": "4.2.6",
+      "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.6.tgz",
+      "integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==",
+      "deprecated": "request-promise has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142",
+      "dev": true,
+      "dependencies": {
+        "bluebird": "^3.5.0",
+        "request-promise-core": "1.1.4",
+        "stealthy-require": "^1.1.1",
+        "tough-cookie": "^2.3.3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "peerDependencies": {
+        "request": "^2.34"
+      }
+    },
     "node_modules/request-promise-core": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz",
@@ -16534,6 +16632,19 @@
         "node": ">=0.8"
       }
     },
+    "node_modules/request-promise/node_modules/tough-cookie": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
+      "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
+      "dev": true,
+      "dependencies": {
+        "psl": "^1.1.28",
+        "punycode": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/request/node_modules/tough-cookie": {
       "version": "2.5.0",
       "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
@@ -18808,6 +18919,13 @@
         "node": ">=8"
       }
     },
+    "node_modules/text-encoding": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz",
+      "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==",
+      "deprecated": "no longer maintained",
+      "dev": true
+    },
     "node_modules/text-table": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -18870,6 +18988,12 @@
       "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz",
       "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q="
     },
+    "node_modules/tls": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/tls/-/tls-0.0.1.tgz",
+      "integrity": "sha1-CrK/WWjXHfL4wOFRXSSiJAuYqsg=",
+      "dev": true
+    },
     "node_modules/tmpl": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz",
@@ -19876,6 +20000,12 @@
       "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-1.1.1.tgz",
       "integrity": "sha512-jYOaqu01Ny1NvMwJ3dBJDUOJ2PGWknZWH4AUnvFOscvbdHMERIKT2TlgiAey5rVyfOePG7so2JcXXZdSnBvioQ=="
     },
+    "node_modules/web-worker": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.1.0.tgz",
+      "integrity": "sha512-BsAzCx5k71qqjPXD5+nBEiLaH/5glNV1OASF2kB+13qkajkScd70mo0E4U0GyhD9nUGgWmSq8dVUqlIOdfICUg==",
+      "dev": true
+    },
     "node_modules/webidl-conversions": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
@@ -25970,6 +26100,12 @@
         "randomfill": "^1.0.3"
       }
     },
+    "crypto-js": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz",
+      "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==",
+      "dev": true
+    },
     "crypto-random-string": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz",
@@ -28813,6 +28949,12 @@
         }
       }
     },
+    "html5-fs": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/html5-fs/-/html5-fs-0.1.1.tgz",
+      "integrity": "sha1-jxRcucLoOLt9D6kx4wraBUtHzX0=",
+      "dev": true
+    },
     "htmlparser2": {
       "version": "3.10.1",
       "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
@@ -31619,6 +31761,53 @@
         "minimist": "^1.2.5"
       }
     },
+    "monero-javascript": {
+      "version": "0.5.5",
+      "resolved": "https://registry.npmjs.org/monero-javascript/-/monero-javascript-0.5.5.tgz",
+      "integrity": "sha512-Fpm0kNxEwvQkHh46l4DEOQk/225og2ujQadeIFqQ1pk9DguIwL2wvLs2xiAgbA3lN+9hEWBdjl1N+UktEIE3Ag==",
+      "dev": true,
+      "requires": {
+        "ajv": "^6.12.6",
+        "async": "2.6.1",
+        "crypto-js": "^4.0.0",
+        "html5-fs": "0.1.1",
+        "net": "^1.0.2",
+        "promise-throttle": "^1.1.2",
+        "request": "^2.88.0",
+        "request-promise": "^4.2.6",
+        "serialize-javascript": "^3.1.0",
+        "text-encoding": "^0.7.0",
+        "tls": "0.0.1",
+        "uuid": "3.3.2",
+        "web-worker": "^1.0.0"
+      },
+      "dependencies": {
+        "async": {
+          "version": "2.6.1",
+          "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz",
+          "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==",
+          "dev": true,
+          "requires": {
+            "lodash": "^4.17.10"
+          }
+        },
+        "serialize-javascript": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-3.1.0.tgz",
+          "integrity": "sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==",
+          "dev": true,
+          "requires": {
+            "randombytes": "^2.1.0"
+          }
+        },
+        "uuid": {
+          "version": "3.3.2",
+          "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz",
+          "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==",
+          "dev": true
+        }
+      }
+    },
     "move-concurrently": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@@ -31713,6 +31902,12 @@
       "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
       "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
     },
+    "net": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/net/-/net-1.0.2.tgz",
+      "integrity": "sha1-0XV+yaf7I3HYPPR1XOPifhCCk4g=",
+      "dev": true
+    },
     "next-tick": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
@@ -33591,6 +33786,12 @@
       "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
       "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM="
     },
+    "promise-throttle": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/promise-throttle/-/promise-throttle-1.1.2.tgz",
+      "integrity": "sha512-dij7vjyXNewuuN/gyr+TX2KRjw48mbV5FEtgyXaIoJjGYAKT0au23/voNvy9eS4UNJjx2KUdEcO5Yyfc1h7vWQ==",
+      "dev": true
+    },
     "prompts": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz",
@@ -34290,6 +34491,30 @@
         }
       }
     },
+    "request-promise": {
+      "version": "4.2.6",
+      "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.6.tgz",
+      "integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==",
+      "dev": true,
+      "requires": {
+        "bluebird": "^3.5.0",
+        "request-promise-core": "1.1.4",
+        "stealthy-require": "^1.1.1",
+        "tough-cookie": "^2.3.3"
+      },
+      "dependencies": {
+        "tough-cookie": {
+          "version": "2.5.0",
+          "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
+          "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
+          "dev": true,
+          "requires": {
+            "psl": "^1.1.28",
+            "punycode": "^2.1.1"
+          }
+        }
+      }
+    },
     "request-promise-core": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz",
@@ -36104,6 +36329,12 @@
         "minimatch": "^3.0.4"
       }
     },
+    "text-encoding": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz",
+      "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==",
+      "dev": true
+    },
     "text-table": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -36165,6 +36396,12 @@
       "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz",
       "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q="
     },
+    "tls": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/tls/-/tls-0.0.1.tgz",
+      "integrity": "sha1-CrK/WWjXHfL4wOFRXSSiJAuYqsg=",
+      "dev": true
+    },
     "tmpl": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz",
@@ -36977,6 +37214,12 @@
       "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-1.1.1.tgz",
       "integrity": "sha512-jYOaqu01Ny1NvMwJ3dBJDUOJ2PGWknZWH4AUnvFOscvbdHMERIKT2TlgiAey5rVyfOePG7so2JcXXZdSnBvioQ=="
     },
+    "web-worker": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.1.0.tgz",
+      "integrity": "sha512-BsAzCx5k71qqjPXD5+nBEiLaH/5glNV1OASF2kB+13qkajkScd70mo0E4U0GyhD9nUGgWmSq8dVUqlIOdfICUg==",
+      "dev": true
+    },
     "webidl-conversions": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
diff --git a/package.json b/package.json
index 8e5e2927..907e1c6c 100644
--- a/package.json
+++ b/package.json
@@ -41,5 +41,8 @@
       "last 1 firefox version",
       "last 1 safari version"
     ]
+  },
+  "devDependencies": {
+    "monero-javascript": "^0.5.5"
   }
 }
diff --git a/src/HavenoDaemon.test.tsx b/src/HavenoDaemon.test.tsx
index 28f0d0b6..e9b58ec7 100644
--- a/src/HavenoDaemon.test.tsx
+++ b/src/HavenoDaemon.test.tsx
@@ -1,20 +1,50 @@
+// import haveno types
 import {HavenoDaemon} from "./HavenoDaemon";
-import {XmrBalanceInfo, OfferInfo} from './protobuf/grpc_pb';
+import {XmrBalanceInfo, OfferInfo} from './protobuf/grpc_pb'; // TODO (woodser): better names; haveno_grpc_pb, haveno_pb
 import {PaymentAccount} from './protobuf/pb_pb';
 
-const HAVENO_UI_VERSION = "1.6.2";
-const HAVENO_DAEMON_URL = "http://localhost:8080";
-const HAVENO_DAEMON_PASSWORD = "apitest";
+// import monero-javascript
+const monerojs = require("monero-javascript"); // TODO (woodser): support typescript and `npm install @types/monero-javascript` in monero-javascript
+const MoneroDaemonRpc = monerojs.MoneroDaemonRpc;
+const MoneroWalletRpc = monerojs.MoneroWalletRpc;
 
-const daemon = new HavenoDaemon(HAVENO_DAEMON_URL, HAVENO_DAEMON_PASSWORD);
+// alice config
+const havenoVersion = "1.6.2";
+const aliceDaemonUrl = "http://localhost:8080";
+const aliceDaemonPassword = "apitest";
+const alice: HavenoDaemon = new HavenoDaemon(aliceDaemonUrl, aliceDaemonPassword);
+const aliceWalletUrl = "http://127.0.0.1:51743"; // alice's internal haveno wallet for direct testing // TODO (woodser): make configurable rather than randomly generated
+const aliceWalletUsername = "rpc_user";
+const aliceWalletPassword = "abc123";
+const aliceWallet = new MoneroWalletRpc(aliceWalletUrl, aliceWalletUsername, aliceWalletPassword);
+const aliceWalletSyncPeriod = 5000;
 
-test("Can get the version", async () => {
-  let version = await daemon.getVersion();
-  expect(version).toEqual(HAVENO_UI_VERSION);
+// bob config
+const bobDaemonUrl = "http://localhost:8081";
+const bobDaemonPassword = "apitest";
+const bob: HavenoDaemon = new HavenoDaemon(bobDaemonUrl, bobDaemonPassword);
+
+// monero daemon config
+const moneroDaemonUrl = "http://localhost:38081"
+const moneroDaemonUsername = "superuser";
+const moneroDaemonPassword = "abctesting123"; 
+let monerod: any;
+
+beforeAll(async () => {
+  await monerojs.LibraryUtils.setWorkerDistPath("./node_modules/monero-javascript/src/main/js/common/MoneroWebWorker.js"); // TODO (woodser): remove this when update to monero-javascript-v0.5.6 which correctly detects node environment
+  monerod = await monerojs.connectToDaemonRpc(moneroDaemonUrl, moneroDaemonUsername, moneroDaemonPassword);
+  //for (let offer of await alice.getMyOffers("BUY")) await alice.removeOffer(offer.getId());
+  //for (let offer of await alice.getMyOffers("SELL")) await alice.removeOffer(offer.getId());
+  //for (let frozenOutput of await aliceWallet.getOutputs({isFrozen: true})) await aliceWallet.thawOutput(frozenOutput.getKeyImage().getHex());
 });
 
-test("Can get the user's balances", async () => {
-  let balances: XmrBalanceInfo = await daemon.getBalances();
+test("Can get the version", async () => {
+  let version = await alice.getVersion();
+  expect(version).toEqual(havenoVersion);
+});
+
+test("Can get balances", async () => {
+  let balances: XmrBalanceInfo = await alice.getBalances();
   expect(balances.getUnlockedBalance());
   expect(balances.getLockedBalance());
   expect(balances.getReservedOfferBalance());
@@ -22,28 +52,28 @@ test("Can get the user's balances", async () => {
 });
 
 test("Can get offers", async () => {
-  let offers: OfferInfo[] = await daemon.getOffers("BUY");
+  let offers: OfferInfo[] = await alice.getOffers("BUY");
   for (let offer of offers) {
     testOffer(offer);
   }
 });
 
-test("Can get the user's created offers", async () => {
-  let offers: OfferInfo[] = await daemon.getMyOffers("SELL");
+test("Can get my offers", async () => {
+  let offers: OfferInfo[] = await alice.getMyOffers("SELL");
   for (let offer of offers) {
     testOffer(offer);
   }
 });
 
 test("Can get payment accounts", async () => {
-  let paymentAccounts: PaymentAccount[] = await daemon.getPaymentAccounts();
+  let paymentAccounts: PaymentAccount[] = await alice.getPaymentAccounts();
   for (let paymentAccount of paymentAccounts) {
     testPaymentAccount(paymentAccount);
   }
 });
 
-test("Can create crypto payment account", async () => {
-  let ethPaymentAccount: PaymentAccount = await daemon.createCryptoPaymentAccount(
+test("Can create a crypto payment account", async () => {
+  let ethPaymentAccount: PaymentAccount = await alice.createCryptoPaymentAccount(
         "my eth account",
         "eth",
         "0xdBdAb835Acd6fC84cF5F9aDD3c0B5a1E25fbd99f",
@@ -52,10 +82,89 @@ test("Can create crypto payment account", async () => {
 });
 
 test("Can post and remove an offer", async () => {
+  
+  // get unlocked balance before reserving funds for offer
+  let unlockedBalanceBefore: bigint = BigInt((await alice.getBalances()).getUnlockedBalance());
+
+  // post offer
+  let offer: OfferInfo = await postOffer();
+  
+  // cancel offer
+  await alice.removeOffer(offer.getId());
+  
+  // offer is removed from my offers
+  if (getOffer(await alice.getMyOffers("buy"), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in my offers after removal");
+  
+  // reserved balance released
+  expect(unlockedBalanceBefore).toEqual(BigInt((await alice.getBalances()).getUnlockedBalance()));
+});
+
+jest.setTimeout(15000);
+test("Invalidates offers when reserved funds are spent", async () => {
+    
+  // get frozen key images before posting offer
+  let frozenKeyImagesBefore = [];
+  for (let frozenOutput of await aliceWallet.getOutputs({isFrozen: true})) frozenKeyImagesBefore.push(frozenOutput.getKeyImage().getHex());
+  
+  // post offer
+  await wait(1000);
+  let offer: OfferInfo = await postOffer();
+  
+  // get key images reserved by offer
+  let reservedKeyImages = [];
+  let frozenKeyImagesAfter = [];
+  for (let frozenOutput of await aliceWallet.getOutputs({isFrozen: true})) frozenKeyImagesAfter.push(frozenOutput.getKeyImage().getHex());
+  for (let frozenKeyImageAfter of frozenKeyImagesAfter) {
+    if (!frozenKeyImagesBefore.includes(frozenKeyImageAfter)) reservedKeyImages.push(frozenKeyImageAfter);
+  }
+  
+  // offer is available to peers
+  await wait(3000);
+  if (!getOffer(await bob.getOffers("buy"), offer.getId())) throw new Error("Offer " + offer.getId() + " was not found in peer's offers after posting");
+  
+  // spend one of offer's reserved outputs
+  if (!reservedKeyImages.length) throw new Error("No reserved key images detected");
+  await aliceWallet.thawOutput(reservedKeyImages[0]);
+  let tx = await aliceWallet.sweepOutput({keyImage: reservedKeyImages[0], address: await aliceWallet.getPrimaryAddress(), relay: false});
+  await monerod.submitTxHex(tx.getFullHex(), true);
+  
+  // wait for spend to be seen
+  await wait(aliceWalletSyncPeriod * 2); // TODO (woodser): need place for common test utilities
+  
+  // offer is removed from peer offers
+  if (getOffer(await bob.getOffers("buy"), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in peer's offers after reserved funds spent");
+  
+  // offer is removed from my offers
+  if (getOffer(await alice.getMyOffers("buy"), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in my offers after reserved funds spent");
+  
+  // offer is automatically cancelled
+  try {
+    await alice.removeOffer(offer.getId());
+    throw new Error("cannot remove invalidated offer");
+  } catch (err) {
+    if (err.message === "cannot remove invalidated offer") throw new Error(err.message);
+  }
+  
+  // flush tx from pool
+  await monerod.flushTxPool(tx.getHash());
+});
+
+test("Can complete a trade", async () => {
+  
+  // wait for alice and bob to have unlocked balance for trade
+  let tradeAmount: bigint = BigInt("250000000000");
+  await waitForUnlockedBalance(tradeAmount, alice, bob);
+  
+  // TODO: finish this test
+});
+
+// ------------------------------- HELPERS ------------------------------------
+
+async function postOffer() {
     
   // test requires ethereum payment account
   let ethPaymentAccount: PaymentAccount | undefined;
-  for (let paymentAccount of await daemon.getPaymentAccounts()) {
+  for (let paymentAccount of await alice.getPaymentAccounts()) {
     if (paymentAccount.getSelectedTradeCurrency()?.getCode() === "ETH") {
       ethPaymentAccount = paymentAccount;
       break;
@@ -64,9 +173,10 @@ test("Can post and remove an offer", async () => {
   if (!ethPaymentAccount) throw new Error("Test requires ethereum payment account to post offer");
   
   // get unlocked balance before reserving offer
-  let unlockedBalanceBefore: bigint = BigInt((await daemon.getBalances()).getUnlockedBalance());
+  let unlockedBalanceBefore: bigint = BigInt((await alice.getBalances()).getUnlockedBalance());
   
   // post offer
+  // TODO: don't define variables, just document in comments
   let amount: bigint = BigInt("250000000000");
   let minAmount: bigint = BigInt("150000000000");
   let price: number = 12.378981; // TODO: price is optional? price string gets converted to long?
@@ -75,7 +185,7 @@ test("Can post and remove an offer", async () => {
   let buyerSecurityDeposit: number = 0.15; // 15%
   let triggerPrice: number = 12; // TODO: fails if there is decimal, gets converted to long?
   let paymentAccountId: string = ethPaymentAccount.getId();
-  let offer: OfferInfo = await daemon.postOffer("eth",
+  let offer: OfferInfo = await alice.postOffer("eth",
         "buy", // buy xmr for eth
         price,
         useMarketBasedPrice,
@@ -88,24 +198,19 @@ test("Can post and remove an offer", async () => {
   testOffer(offer);
 
   // unlocked balance has decreased
-  let unlockedBalanceAfter: bigint = BigInt((await daemon.getBalances()).getUnlockedBalance());
+  let unlockedBalanceAfter: bigint = BigInt((await alice.getBalances()).getUnlockedBalance());
   expect(unlockedBalanceAfter).toBeLessThan(unlockedBalanceBefore);
   
   // offer is included in my offers only
-  if (!getOffer(await daemon.getMyOffers("buy"), offer.getId())) throw new Error("Offer " + offer.getId() + " was not found in my offers");
-  if (getOffer(await daemon.getOffers("buy"), offer.getId())) throw new Error("My offer " + offer.getId() + " should not appear in available offers");
+  if (!getOffer(await alice.getMyOffers("buy"), offer.getId())) throw new Error("Offer " + offer.getId() + " was not found in my offers");
+  if (getOffer(await alice.getOffers("buy"), offer.getId())) throw new Error("My offer " + offer.getId() + " should not appear in available offers");
   
-  // cancel the offer
-  await daemon.cancelOffer(offer.getId());
-  
-  // offer is removed from my offers
-  if (getOffer(await daemon.getOffers("buy"), offer.getId())) throw new Error("Offer " + offer.getId() + " was found in my offers after removal");
-  
-  // reserved balance restored
-  expect(unlockedBalanceBefore).toEqual(BigInt((await daemon.getBalances()).getUnlockedBalance()));
-});
+  return offer;
+}
 
-// ------------------------------- HELPERS ------------------------------------
+async function waitForUnlockedBalance(amount: bigint, ...clients: HavenoDaemon[]) {
+  throw new Error("waitForUnlockedFunds() not implemented"); // TODO: implement
+}
 
 function getOffer(offers: OfferInfo[], id: string): OfferInfo | undefined {
   return offers.find(offer => offer.getId() === id);
@@ -113,10 +218,14 @@ function getOffer(offers: OfferInfo[], id: string): OfferInfo | undefined {
 
 function testPaymentAccount(paymentAccount: PaymentAccount) {
   expect(paymentAccount.getId()).toHaveLength;
-  // TODO: test rest of offer
+  // TODO: test rest of payment account
 }
 
 function testOffer(offer: OfferInfo) {
   expect(offer.getId()).toHaveLength;
   // TODO: test rest of offer
+}
+
+async function wait(durationMs: number) {
+  return new Promise(function(resolve) { setTimeout(resolve, durationMs); });
 }
\ No newline at end of file
diff --git a/src/HavenoDaemon.tsx b/src/HavenoDaemon.tsx
index f0eec6f8..d502f980 100644
--- a/src/HavenoDaemon.tsx
+++ b/src/HavenoDaemon.tsx
@@ -192,11 +192,11 @@ class HavenoDaemon {
   }
   
   /**
-   * Remove a posted offer, unreserving its funds.
+   * Remove a posted offer, releasing its reserved funds.
    * 
    * @param {string} id - the offer id to cancel
    */
-  async cancelOffer(id: string): Promise {
+  async removeOffer(id: string): Promise {
     let that = this;
     return new Promise(function(resolve, reject) {
       that._offersClient.cancelOffer(new CancelOfferRequest().setId(id), {password: that._password}, function(err: grpcWeb.Error) {
diff --git a/src/protobuf/pb_pb.d.ts b/src/protobuf/pb_pb.d.ts
index d28bf844..fbb8493e 100644
--- a/src/protobuf/pb_pb.d.ts
+++ b/src/protobuf/pb_pb.d.ts
@@ -788,6 +788,11 @@ export class SignOfferRequest extends jspb.Message {
   getReserveTxKey(): string;
   setReserveTxKey(value: string): SignOfferRequest;
 
+  getReserveTxKeyImagesList(): Array;
+  setReserveTxKeyImagesList(value: Array): SignOfferRequest;
+  clearReserveTxKeyImagesList(): SignOfferRequest;
+  addReserveTxKeyImages(value: string, index?: number): SignOfferRequest;
+
   getPayoutAddress(): string;
   setPayoutAddress(value: string): SignOfferRequest;
 
@@ -811,6 +816,7 @@ export namespace SignOfferRequest {
     reserveTxHash: string,
     reserveTxHex: string,
     reserveTxKey: string,
+    reserveTxKeyImagesList: Array,
     payoutAddress: string,
   }
 }
@@ -4138,6 +4144,11 @@ export class OfferPayload extends jspb.Message {
   getArbitratorSignature(): string;
   setArbitratorSignature(value: string): OfferPayload;
 
+  getReserveTxKeyImagesList(): Array;
+  setReserveTxKeyImagesList(value: Array): OfferPayload;
+  clearReserveTxKeyImagesList(): OfferPayload;
+  addReserveTxKeyImages(value: string, index?: number): OfferPayload;
+
   serializeBinary(): Uint8Array;
   toObject(includeInstance?: boolean): OfferPayload.AsObject;
   static toObject(includeInstance: boolean, msg: OfferPayload): OfferPayload.AsObject;
@@ -4186,6 +4197,7 @@ export namespace OfferPayload {
     protocolVersion: number,
     arbitratorNodeAddress?: NodeAddress.AsObject,
     arbitratorSignature: string,
+    reserveTxKeyImagesList: Array,
   }
 
   export enum Direction { 
diff --git a/src/protobuf/pb_pb.js b/src/protobuf/pb_pb.js
index f92046f8..92c8fd79 100644
--- a/src/protobuf/pb_pb.js
+++ b/src/protobuf/pb_pb.js
@@ -537,7 +537,7 @@ if (goog.DEBUG && !COMPILED) {
  * @constructor
  */
 proto.io.bisq.protobuffer.SignOfferRequest = function(opt_data) {
-  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+  jspb.Message.initialize(this, opt_data, 0, -1, proto.io.bisq.protobuffer.SignOfferRequest.repeatedFields_, null);
 };
 goog.inherits(proto.io.bisq.protobuffer.SignOfferRequest, jspb.Message);
 if (goog.DEBUG && !COMPILED) {
@@ -11148,6 +11148,13 @@ proto.io.bisq.protobuffer.GetInventoryResponse.prototype.clearInventoryMap = fun
 
 
 
+/**
+ * List of repeated fields within this message type.
+ * @private {!Array}
+ * @const
+ */
+proto.io.bisq.protobuffer.SignOfferRequest.repeatedFields_ = [11];
+
 
 
 if (jspb.Message.GENERATE_TO_OBJECT) {
@@ -11189,7 +11196,8 @@ proto.io.bisq.protobuffer.SignOfferRequest.toObject = function(includeInstance,
     reserveTxHash: jspb.Message.getFieldWithDefault(msg, 8, ""),
     reserveTxHex: jspb.Message.getFieldWithDefault(msg, 9, ""),
     reserveTxKey: jspb.Message.getFieldWithDefault(msg, 10, ""),
-    payoutAddress: jspb.Message.getFieldWithDefault(msg, 11, "")
+    reserveTxKeyImagesList: (f = jspb.Message.getRepeatedField(msg, 11)) == null ? undefined : f,
+    payoutAddress: jspb.Message.getFieldWithDefault(msg, 12, "")
   };
 
   if (includeInstance) {
@@ -11270,6 +11278,10 @@ proto.io.bisq.protobuffer.SignOfferRequest.deserializeBinaryFromReader = functio
       msg.setReserveTxKey(value);
       break;
     case 11:
+      var value = /** @type {string} */ (reader.readString());
+      msg.addReserveTxKeyImages(value);
+      break;
+    case 12:
       var value = /** @type {string} */ (reader.readString());
       msg.setPayoutAddress(value);
       break;
@@ -11375,10 +11387,17 @@ proto.io.bisq.protobuffer.SignOfferRequest.serializeBinaryToWriter = function(me
       f
     );
   }
+  f = message.getReserveTxKeyImagesList();
+  if (f.length > 0) {
+    writer.writeRepeatedString(
+      11,
+      f
+    );
+  }
   f = message.getPayoutAddress();
   if (f.length > 0) {
     writer.writeString(
-      11,
+      12,
       f
     );
   }
@@ -11623,11 +11642,48 @@ proto.io.bisq.protobuffer.SignOfferRequest.prototype.setReserveTxKey = function(
 
 
 /**
- * optional string payout_address = 11;
+ * repeated string reserve_tx_key_images = 11;
+ * @return {!Array}
+ */
+proto.io.bisq.protobuffer.SignOfferRequest.prototype.getReserveTxKeyImagesList = function() {
+  return /** @type {!Array} */ (jspb.Message.getRepeatedField(this, 11));
+};
+
+
+/**
+ * @param {!Array} value
+ * @return {!proto.io.bisq.protobuffer.SignOfferRequest} returns this
+ */
+proto.io.bisq.protobuffer.SignOfferRequest.prototype.setReserveTxKeyImagesList = function(value) {
+  return jspb.Message.setField(this, 11, value || []);
+};
+
+
+/**
+ * @param {string} value
+ * @param {number=} opt_index
+ * @return {!proto.io.bisq.protobuffer.SignOfferRequest} returns this
+ */
+proto.io.bisq.protobuffer.SignOfferRequest.prototype.addReserveTxKeyImages = function(value, opt_index) {
+  return jspb.Message.addToRepeatedField(this, 11, value, opt_index);
+};
+
+
+/**
+ * Clears the list making it empty but non-null.
+ * @return {!proto.io.bisq.protobuffer.SignOfferRequest} returns this
+ */
+proto.io.bisq.protobuffer.SignOfferRequest.prototype.clearReserveTxKeyImagesList = function() {
+  return this.setReserveTxKeyImagesList([]);
+};
+
+
+/**
+ * optional string payout_address = 12;
  * @return {string}
  */
 proto.io.bisq.protobuffer.SignOfferRequest.prototype.getPayoutAddress = function() {
-  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 11, ""));
+  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 12, ""));
 };
 
 
@@ -11636,7 +11692,7 @@ proto.io.bisq.protobuffer.SignOfferRequest.prototype.getPayoutAddress = function
  * @return {!proto.io.bisq.protobuffer.SignOfferRequest} returns this
  */
 proto.io.bisq.protobuffer.SignOfferRequest.prototype.setPayoutAddress = function(value) {
-  return jspb.Message.setProto3StringField(this, 11, value);
+  return jspb.Message.setProto3StringField(this, 12, value);
 };
 
 
@@ -36401,7 +36457,7 @@ proto.io.bisq.protobuffer.MailboxStoragePayload.prototype.clearExtraDataMap = fu
  * @private {!Array}
  * @const
  */
-proto.io.bisq.protobuffer.OfferPayload.repeatedFields_ = [17,19];
+proto.io.bisq.protobuffer.OfferPayload.repeatedFields_ = [17,19,1003];
 
 
 
@@ -36471,7 +36527,8 @@ proto.io.bisq.protobuffer.OfferPayload.toObject = function(includeInstance, msg)
     extraDataMap: (f = msg.getExtraDataMap()) ? f.toObject(includeInstance, undefined) : [],
     protocolVersion: jspb.Message.getFieldWithDefault(msg, 36, 0),
     arbitratorNodeAddress: (f = msg.getArbitratorNodeAddress()) && proto.io.bisq.protobuffer.NodeAddress.toObject(includeInstance, f),
-    arbitratorSignature: jspb.Message.getFieldWithDefault(msg, 1002, "")
+    arbitratorSignature: jspb.Message.getFieldWithDefault(msg, 1002, ""),
+    reserveTxKeyImagesList: (f = jspb.Message.getRepeatedField(msg, 1003)) == null ? undefined : f
   };
 
   if (includeInstance) {
@@ -36665,6 +36722,10 @@ proto.io.bisq.protobuffer.OfferPayload.deserializeBinaryFromReader = function(ms
       var value = /** @type {string} */ (reader.readString());
       msg.setArbitratorSignature(value);
       break;
+    case 1003:
+      var value = /** @type {string} */ (reader.readString());
+      msg.addReserveTxKeyImages(value);
+      break;
     default:
       reader.skipField();
       break;
@@ -36960,6 +37021,13 @@ proto.io.bisq.protobuffer.OfferPayload.serializeBinaryToWriter = function(messag
       f
     );
   }
+  f = message.getReserveTxKeyImagesList();
+  if (f.length > 0) {
+    writer.writeRepeatedString(
+      1003,
+      f
+    );
+  }
 };
 
 
@@ -37755,6 +37823,43 @@ proto.io.bisq.protobuffer.OfferPayload.prototype.setArbitratorSignature = functi
 };
 
 
+/**
+ * repeated string reserve_tx_key_images = 1003;
+ * @return {!Array}
+ */
+proto.io.bisq.protobuffer.OfferPayload.prototype.getReserveTxKeyImagesList = function() {
+  return /** @type {!Array} */ (jspb.Message.getRepeatedField(this, 1003));
+};
+
+
+/**
+ * @param {!Array} value
+ * @return {!proto.io.bisq.protobuffer.OfferPayload} returns this
+ */
+proto.io.bisq.protobuffer.OfferPayload.prototype.setReserveTxKeyImagesList = function(value) {
+  return jspb.Message.setField(this, 1003, value || []);
+};
+
+
+/**
+ * @param {string} value
+ * @param {number=} opt_index
+ * @return {!proto.io.bisq.protobuffer.OfferPayload} returns this
+ */
+proto.io.bisq.protobuffer.OfferPayload.prototype.addReserveTxKeyImages = function(value, opt_index) {
+  return jspb.Message.addToRepeatedField(this, 1003, value, opt_index);
+};
+
+
+/**
+ * Clears the list making it empty but non-null.
+ * @return {!proto.io.bisq.protobuffer.OfferPayload} returns this
+ */
+proto.io.bisq.protobuffer.OfferPayload.prototype.clearReserveTxKeyImagesList = function() {
+  return this.setReserveTxKeyImagesList([]);
+};
+
+