From c168c652de2005db6392d034cccba00bd4bdfddc Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 09:10:39 -0700 Subject: [PATCH] Creating mono-repo from psf-memo-client --- .gitignore | 7 + bb.edn | 10 + close-swarm | 75 + doc/plan-swarmforge-dir.md | 97 + doc/quality-level-proposal.md | 154 + doc/sf-handoff-protocol.md | 271 + doc/swarmforge.md | 308 + psf-memo-client/.env.development | 4 + psf-memo-client/.env.example | 3 + psf-memo-client/.gitignore | 7 + psf-memo-client/.on-save.json | 8 + psf-memo-client/LICENSE.md | 7 + psf-memo-client/PEDIGREE.md | 3 + psf-memo-client/README.md | 24 + psf-memo-client/acceptance/acceptance.js | 99 + psf-memo-client/acceptance/lib/generate.js | 112 + psf-memo-client/acceptance/lib/handlers.js | 814 + .../acceptance/lib/runner-worker.js | 68 + psf-memo-client/acceptance/lib/runtime.js | 69 + psf-memo-client/deploy/README.md | 17 + psf-memo-client/deploy/publish-bch.js | 52 + psf-memo-client/deploy/publish-filecoin.js | 82 + psf-memo-client/deploy/publish-gh-pages.sh | 14 + psf-memo-client/deploy/publish-main.js | 29 + psf-memo-client/deploy/publish-pinata.js | 52 + psf-memo-client/dev-docs/README.md | 28 + .../dev-docs/psf-memo-db-changes.md | 63 + psf-memo-client/img/donation-qr.png | Bin 0 -> 2028 bytes psf-memo-client/llm-bch-wallet-web3-spa.md | 4439 +++ psf-memo-client/package-lock.json | 26749 ++++++++++++++++ psf-memo-client/package.json | 74 + psf-memo-client/public/index.html | 12 + .../public/minimal-slp-wallet.min.js | 1 + psf-memo-client/specifier-prompt.md | 285 + psf-memo-client/specs/feature-backlog.md | 156 + psf-memo-client/specs/like-tip-memo.feature | 117 + psf-memo-client/specs/memo-new.feature | 75 + psf-memo-client/specs/post-memo.feature | 44 + psf-memo-client/specs/reply-memo.feature | 90 + psf-memo-client/specs/set-name.feature | 65 + psf-memo-client/src/App.css | 1006 + psf-memo-client/src/App.js | 195 + psf-memo-client/src/App.test.js | 9 + .../src/components/app-body/account/index.js | 101 + .../src/components/app-body/balance.js | 70 + .../app-body/bch-send/balance-card.js | 86 + .../src/components/app-body/bch-send/index.js | 64 + .../app-body/bch-send/receive-card.js | 93 + .../app-body/bch-send/refresh-balance.js | 93 + .../bch-send/refresh-bch-balance-button.js | 44 + .../components/app-body/bch-send/send-card.js | 336 + .../app-body/bch-wallet/clear-wallet.js | 59 + .../app-body/bch-wallet/copy-on-click.js | 55 + .../app-body/bch-wallet/import-wallet.js | 111 + .../components/app-body/bch-wallet/index.js | 47 + .../app-body/bch-wallet/optimize-wallet.js | 113 + .../app-body/bch-wallet/wallet-summary.css | 4 + .../app-body/bch-wallet/wallet-summary.js | 150 + .../components/app-body/bch-wallet/warning.js | 49 + .../app-body/configuration/index.js | 20 + .../configuration/select-server-button.js | 47 + .../configuration/select-server-view.js | 90 + .../src/components/app-body/index.js | 65 + .../src/components/app-body/new-post/index.js | 94 + .../components/app-body/placeholder-view.js | 22 + .../src/components/app-body/placeholder2.js | 12 + .../src/components/app-body/placeholder3.js | 12 + .../src/components/app-body/posts/index.js | 175 + .../src/components/app-body/profile/index.js | 179 + .../components/app-body/profile/profile.css | 87 + .../app-body/recent-profiles/index.js | 111 + .../src/components/app-body/set-name/index.js | 94 + .../src/components/app-body/sign/index.js | 120 + .../components/app-body/slp-tokens/index.js | 238 + .../app-body/slp-tokens/info-button.js | 145 + .../app-body/slp-tokens/refresh-tokens.js | 108 + .../app-body/slp-tokens/send-token-button.js | 223 + .../app-body/slp-tokens/token-card.js | 83 + .../src/components/app-body/sweep/index.js | 188 + .../components/app-body/user-data-review.js | 127 + .../src/components/footer/get-cid.js | 70 + .../src/components/footer/index.js | 75 + .../src/components/load-scripts.js | 16 + .../src/components/nav-menu/index.js | 133 + .../src/components/nav-menu/psf-logo.png | Bin 0 -> 44488 bytes .../src/components/post-feed/like-button.js | 37 + .../components/post-feed/like-tip-modal.js | 157 + .../src/components/post-feed/post-display.js | 39 + .../components/post-feed/post-feed-item.js | 196 + .../src/components/post-feed/post-feed.css | 664 + .../src/components/post-reply-count/index.js | 49 + .../post-reply-count/post-reply-count.css | 27 + .../src/components/post-thread-modal/index.js | 133 + .../post-thread-modal/post-thread-avatar.js | 35 + .../post-thread-modal/post-thread-modal.css | 105 + .../post-thread-modal/post-thread-node.js | 43 + .../post-thread-modal/reply-thread-form.js | 100 + .../post-thread-modal/thread-profiles.js | 40 + .../src/components/starter-views.js | 41 + .../src/components/waiting-modal/index.js | 74 + psf-memo-client/src/config/index.js | 23 + psf-memo-client/src/hooks/state.js | 165 + psf-memo-client/src/hooks/use-script.js | 18 + psf-memo-client/src/index.js | 25 + psf-memo-client/src/services/account-page.js | 57 + psf-memo-client/src/services/async-load.js | 255 + psf-memo-client/src/services/gist-servers.js | 36 + psf-memo-client/src/services/hex.js | 28 + psf-memo-client/src/services/like-tip-page.js | 106 + psf-memo-client/src/services/memo-action.js | 77 + psf-memo-client/src/services/memo-db.js | 91 + psf-memo-client/src/services/memo-like.js | 202 + psf-memo-client/src/services/memo-post.js | 69 + psf-memo-client/src/services/memo-reply.js | 101 + psf-memo-client/src/services/memo-set-name.js | 66 + psf-memo-client/src/services/new-post.js | 72 + .../src/services/optimistic-reply.js | 23 + .../src/services/page-controller.js | 65 + psf-memo-client/src/services/profiles.js | 32 + .../src/services/reply-thread-page.js | 63 + psf-memo-client/src/services/set-name-page.js | 58 + psf-memo-client/src/services/utf8.js | 19 + psf-memo-client/src/util/index.js | 58 + swarm | 29 + swarmforge/constitution.prompt | 4 + .../constitution/articles/engineering.prompt | 45 + .../constitution/articles/handoffs.prompt | 69 + .../constitution/articles/project.prompt | 15 + .../constitution/articles/workflow.prompt | 27 + swarmforge/roles/architect.prompt | 59 + swarmforge/roles/coder.prompt | 29 + swarmforge/roles/refactorer.prompt | 29 + swarmforge/roles/specifier.prompt | 30 + swarmforge/scripts/done_with_current.bb | 66 + swarmforge/scripts/done_with_current.sh | 5 + swarmforge/scripts/done_with_current_batch.bb | 106 + swarmforge/scripts/done_with_current_batch.sh | 5 + swarmforge/scripts/done_with_current_task.bb | 95 + swarmforge/scripts/done_with_current_task.sh | 5 + swarmforge/scripts/handoff_lib.bb | 190 + swarmforge/scripts/handoffd.bb | 202 + swarmforge/scripts/ready_for_next.bb | 66 + swarmforge/scripts/ready_for_next.sh | 5 + swarmforge/scripts/ready_for_next_batch.bb | 148 + swarmforge/scripts/ready_for_next_batch.sh | 5 + swarmforge/scripts/ready_for_next_task.bb | 120 + swarmforge/scripts/ready_for_next_task.sh | 5 + swarmforge/scripts/shared-articles/.gitkeep | 0 swarmforge/scripts/stop_handoff_daemon.bb | 45 + swarmforge/scripts/stop_handoff_daemon.sh | 5 + swarmforge/scripts/swarm-cleanup.sh | 48 + swarmforge/scripts/swarm-terminal-adapter.sh | 60 + swarmforge/scripts/swarm-window-watchdog.bb | 119 + swarmforge/scripts/swarm-window-watchdog.sh | 5 + swarmforge/scripts/swarm_handoff.bb | 338 + swarmforge/scripts/swarm_handoff.sh | 5 + swarmforge/scripts/swarmforge.bb | 603 + swarmforge/scripts/swarmforge.sh | 5 + .../scripts/terminal-adapters/ghostty.sh | 110 + .../scripts/terminal-adapters/iterm2.sh | 87 + swarmforge/scripts/terminal-adapters/none.sh | 25 + .../scripts/terminal-adapters/terminal-app.sh | 72 + .../terminal-adapters/windows-terminal.sh | 36 + swarmforge/swarmforge.conf | 14 + test/helpers/fake-profiles.js | 13 + test/helpers/fake-wallet.js | 34 + test/property/behavior-helpers.js | 117 + test/property/harness.js | 48 + test/property/like-tip.property.test.js | 116 + test/property/memo-post.property.test.js | 67 + test/property/set-name.property.test.js | 45 + test/swarmforge/handoff_test.clj | 285 + test/swarmforge/script_test.clj | 350 + test/unit/account-page.test.js | 75 + test/unit/hex.test.js | 50 + test/unit/like-tip-page.test.js | 239 + test/unit/memo-action-helpers.js | 107 + test/unit/memo-like.test.js | 293 + test/unit/memo-post.test.js | 68 + test/unit/memo-reply.test.js | 115 + test/unit/memo-set-name.test.js | 88 + test/unit/new-post.test.js | 129 + test/unit/optimistic-reply.test.js | 66 + test/unit/page-build-helpers.js | 28 + test/unit/page-controller-helpers.js | 131 + test/unit/profiles.test.js | 49 + test/unit/reply-thread-page.test.js | 133 + test/unit/set-name-page.test.js | 129 + test/unit/utf8.test.js | 38 + tools/sf-queue | 36 + tools/sf-tokens | 58 + 191 files changed, 49095 insertions(+) create mode 100644 .gitignore create mode 100644 bb.edn create mode 100755 close-swarm create mode 100644 doc/plan-swarmforge-dir.md create mode 100644 doc/quality-level-proposal.md create mode 100644 doc/sf-handoff-protocol.md create mode 100644 doc/swarmforge.md create mode 100644 psf-memo-client/.env.development create mode 100644 psf-memo-client/.env.example create mode 100644 psf-memo-client/.gitignore create mode 100644 psf-memo-client/.on-save.json create mode 100644 psf-memo-client/LICENSE.md create mode 100644 psf-memo-client/PEDIGREE.md create mode 100644 psf-memo-client/README.md create mode 100644 psf-memo-client/acceptance/acceptance.js create mode 100644 psf-memo-client/acceptance/lib/generate.js create mode 100644 psf-memo-client/acceptance/lib/handlers.js create mode 100644 psf-memo-client/acceptance/lib/runner-worker.js create mode 100644 psf-memo-client/acceptance/lib/runtime.js create mode 100644 psf-memo-client/deploy/README.md create mode 100644 psf-memo-client/deploy/publish-bch.js create mode 100644 psf-memo-client/deploy/publish-filecoin.js create mode 100755 psf-memo-client/deploy/publish-gh-pages.sh create mode 100644 psf-memo-client/deploy/publish-main.js create mode 100644 psf-memo-client/deploy/publish-pinata.js create mode 100644 psf-memo-client/dev-docs/README.md create mode 100644 psf-memo-client/dev-docs/psf-memo-db-changes.md create mode 100644 psf-memo-client/img/donation-qr.png create mode 100644 psf-memo-client/llm-bch-wallet-web3-spa.md create mode 100644 psf-memo-client/package-lock.json create mode 100644 psf-memo-client/package.json create mode 100644 psf-memo-client/public/index.html create mode 100644 psf-memo-client/public/minimal-slp-wallet.min.js create mode 100644 psf-memo-client/specifier-prompt.md create mode 100644 psf-memo-client/specs/feature-backlog.md create mode 100644 psf-memo-client/specs/like-tip-memo.feature create mode 100644 psf-memo-client/specs/memo-new.feature create mode 100644 psf-memo-client/specs/post-memo.feature create mode 100644 psf-memo-client/specs/reply-memo.feature create mode 100644 psf-memo-client/specs/set-name.feature create mode 100644 psf-memo-client/src/App.css create mode 100644 psf-memo-client/src/App.js create mode 100644 psf-memo-client/src/App.test.js create mode 100644 psf-memo-client/src/components/app-body/account/index.js create mode 100644 psf-memo-client/src/components/app-body/balance.js create mode 100644 psf-memo-client/src/components/app-body/bch-send/balance-card.js create mode 100644 psf-memo-client/src/components/app-body/bch-send/index.js create mode 100644 psf-memo-client/src/components/app-body/bch-send/receive-card.js create mode 100644 psf-memo-client/src/components/app-body/bch-send/refresh-balance.js create mode 100644 psf-memo-client/src/components/app-body/bch-send/refresh-bch-balance-button.js create mode 100644 psf-memo-client/src/components/app-body/bch-send/send-card.js create mode 100644 psf-memo-client/src/components/app-body/bch-wallet/clear-wallet.js create mode 100644 psf-memo-client/src/components/app-body/bch-wallet/copy-on-click.js create mode 100644 psf-memo-client/src/components/app-body/bch-wallet/import-wallet.js create mode 100644 psf-memo-client/src/components/app-body/bch-wallet/index.js create mode 100644 psf-memo-client/src/components/app-body/bch-wallet/optimize-wallet.js create mode 100644 psf-memo-client/src/components/app-body/bch-wallet/wallet-summary.css create mode 100644 psf-memo-client/src/components/app-body/bch-wallet/wallet-summary.js create mode 100644 psf-memo-client/src/components/app-body/bch-wallet/warning.js create mode 100644 psf-memo-client/src/components/app-body/configuration/index.js create mode 100644 psf-memo-client/src/components/app-body/configuration/select-server-button.js create mode 100644 psf-memo-client/src/components/app-body/configuration/select-server-view.js create mode 100644 psf-memo-client/src/components/app-body/index.js create mode 100644 psf-memo-client/src/components/app-body/new-post/index.js create mode 100644 psf-memo-client/src/components/app-body/placeholder-view.js create mode 100644 psf-memo-client/src/components/app-body/placeholder2.js create mode 100644 psf-memo-client/src/components/app-body/placeholder3.js create mode 100644 psf-memo-client/src/components/app-body/posts/index.js create mode 100644 psf-memo-client/src/components/app-body/profile/index.js create mode 100644 psf-memo-client/src/components/app-body/profile/profile.css create mode 100644 psf-memo-client/src/components/app-body/recent-profiles/index.js create mode 100644 psf-memo-client/src/components/app-body/set-name/index.js create mode 100644 psf-memo-client/src/components/app-body/sign/index.js create mode 100644 psf-memo-client/src/components/app-body/slp-tokens/index.js create mode 100644 psf-memo-client/src/components/app-body/slp-tokens/info-button.js create mode 100644 psf-memo-client/src/components/app-body/slp-tokens/refresh-tokens.js create mode 100644 psf-memo-client/src/components/app-body/slp-tokens/send-token-button.js create mode 100644 psf-memo-client/src/components/app-body/slp-tokens/token-card.js create mode 100644 psf-memo-client/src/components/app-body/sweep/index.js create mode 100644 psf-memo-client/src/components/app-body/user-data-review.js create mode 100644 psf-memo-client/src/components/footer/get-cid.js create mode 100644 psf-memo-client/src/components/footer/index.js create mode 100644 psf-memo-client/src/components/load-scripts.js create mode 100644 psf-memo-client/src/components/nav-menu/index.js create mode 100644 psf-memo-client/src/components/nav-menu/psf-logo.png create mode 100644 psf-memo-client/src/components/post-feed/like-button.js create mode 100644 psf-memo-client/src/components/post-feed/like-tip-modal.js create mode 100644 psf-memo-client/src/components/post-feed/post-display.js create mode 100644 psf-memo-client/src/components/post-feed/post-feed-item.js create mode 100644 psf-memo-client/src/components/post-feed/post-feed.css create mode 100644 psf-memo-client/src/components/post-reply-count/index.js create mode 100644 psf-memo-client/src/components/post-reply-count/post-reply-count.css create mode 100644 psf-memo-client/src/components/post-thread-modal/index.js create mode 100644 psf-memo-client/src/components/post-thread-modal/post-thread-avatar.js create mode 100644 psf-memo-client/src/components/post-thread-modal/post-thread-modal.css create mode 100644 psf-memo-client/src/components/post-thread-modal/post-thread-node.js create mode 100644 psf-memo-client/src/components/post-thread-modal/reply-thread-form.js create mode 100644 psf-memo-client/src/components/post-thread-modal/thread-profiles.js create mode 100644 psf-memo-client/src/components/starter-views.js create mode 100644 psf-memo-client/src/components/waiting-modal/index.js create mode 100644 psf-memo-client/src/config/index.js create mode 100644 psf-memo-client/src/hooks/state.js create mode 100644 psf-memo-client/src/hooks/use-script.js create mode 100644 psf-memo-client/src/index.js create mode 100644 psf-memo-client/src/services/account-page.js create mode 100644 psf-memo-client/src/services/async-load.js create mode 100644 psf-memo-client/src/services/gist-servers.js create mode 100644 psf-memo-client/src/services/hex.js create mode 100644 psf-memo-client/src/services/like-tip-page.js create mode 100644 psf-memo-client/src/services/memo-action.js create mode 100644 psf-memo-client/src/services/memo-db.js create mode 100644 psf-memo-client/src/services/memo-like.js create mode 100644 psf-memo-client/src/services/memo-post.js create mode 100644 psf-memo-client/src/services/memo-reply.js create mode 100644 psf-memo-client/src/services/memo-set-name.js create mode 100644 psf-memo-client/src/services/new-post.js create mode 100644 psf-memo-client/src/services/optimistic-reply.js create mode 100644 psf-memo-client/src/services/page-controller.js create mode 100644 psf-memo-client/src/services/profiles.js create mode 100644 psf-memo-client/src/services/reply-thread-page.js create mode 100644 psf-memo-client/src/services/set-name-page.js create mode 100644 psf-memo-client/src/services/utf8.js create mode 100644 psf-memo-client/src/util/index.js create mode 100755 swarm create mode 100644 swarmforge/constitution.prompt create mode 100644 swarmforge/constitution/articles/engineering.prompt create mode 100644 swarmforge/constitution/articles/handoffs.prompt create mode 100644 swarmforge/constitution/articles/project.prompt create mode 100644 swarmforge/constitution/articles/workflow.prompt create mode 100644 swarmforge/roles/architect.prompt create mode 100644 swarmforge/roles/coder.prompt create mode 100644 swarmforge/roles/refactorer.prompt create mode 100644 swarmforge/roles/specifier.prompt create mode 100755 swarmforge/scripts/done_with_current.bb create mode 100755 swarmforge/scripts/done_with_current.sh create mode 100755 swarmforge/scripts/done_with_current_batch.bb create mode 100755 swarmforge/scripts/done_with_current_batch.sh create mode 100755 swarmforge/scripts/done_with_current_task.bb create mode 100755 swarmforge/scripts/done_with_current_task.sh create mode 100755 swarmforge/scripts/handoff_lib.bb create mode 100755 swarmforge/scripts/handoffd.bb create mode 100755 swarmforge/scripts/ready_for_next.bb create mode 100755 swarmforge/scripts/ready_for_next.sh create mode 100755 swarmforge/scripts/ready_for_next_batch.bb create mode 100755 swarmforge/scripts/ready_for_next_batch.sh create mode 100755 swarmforge/scripts/ready_for_next_task.bb create mode 100755 swarmforge/scripts/ready_for_next_task.sh create mode 100644 swarmforge/scripts/shared-articles/.gitkeep create mode 100755 swarmforge/scripts/stop_handoff_daemon.bb create mode 100755 swarmforge/scripts/stop_handoff_daemon.sh create mode 100755 swarmforge/scripts/swarm-cleanup.sh create mode 100755 swarmforge/scripts/swarm-terminal-adapter.sh create mode 100755 swarmforge/scripts/swarm-window-watchdog.bb create mode 100755 swarmforge/scripts/swarm-window-watchdog.sh create mode 100755 swarmforge/scripts/swarm_handoff.bb create mode 100755 swarmforge/scripts/swarm_handoff.sh create mode 100755 swarmforge/scripts/swarmforge.bb create mode 100755 swarmforge/scripts/swarmforge.sh create mode 100755 swarmforge/scripts/terminal-adapters/ghostty.sh create mode 100755 swarmforge/scripts/terminal-adapters/iterm2.sh create mode 100755 swarmforge/scripts/terminal-adapters/none.sh create mode 100755 swarmforge/scripts/terminal-adapters/terminal-app.sh create mode 100755 swarmforge/scripts/terminal-adapters/windows-terminal.sh create mode 100644 swarmforge/swarmforge.conf create mode 100644 test/helpers/fake-profiles.js create mode 100644 test/helpers/fake-wallet.js create mode 100644 test/property/behavior-helpers.js create mode 100644 test/property/harness.js create mode 100644 test/property/like-tip.property.test.js create mode 100644 test/property/memo-post.property.test.js create mode 100644 test/property/set-name.property.test.js create mode 100644 test/swarmforge/handoff_test.clj create mode 100644 test/swarmforge/script_test.clj create mode 100644 test/unit/account-page.test.js create mode 100644 test/unit/hex.test.js create mode 100644 test/unit/like-tip-page.test.js create mode 100644 test/unit/memo-action-helpers.js create mode 100644 test/unit/memo-like.test.js create mode 100644 test/unit/memo-post.test.js create mode 100644 test/unit/memo-reply.test.js create mode 100644 test/unit/memo-set-name.test.js create mode 100644 test/unit/new-post.test.js create mode 100644 test/unit/optimistic-reply.test.js create mode 100644 test/unit/page-build-helpers.js create mode 100644 test/unit/page-controller-helpers.js create mode 100644 test/unit/profiles.test.js create mode 100644 test/unit/reply-thread-page.test.js create mode 100644 test/unit/set-name-page.test.js create mode 100644 test/unit/utf8.test.js create mode 100755 tools/sf-queue create mode 100755 tools/sf-tokens diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..861bf9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +build/ +docs/ +tmp/ +target/ + +.gitsigners diff --git a/bb.edn b/bb.edn new file mode 100644 index 0000000..def1fe3 --- /dev/null +++ b/bb.edn @@ -0,0 +1,10 @@ +{:paths ["test"] + :tasks + {test {:doc "Run helper tests" + :task (do + (require 'clojure.test) + (require 'swarmforge.handoff-test) + (require 'swarmforge.script-test) + (let [{:keys [fail error]} (clojure.test/run-tests 'swarmforge.handoff-test + 'swarmforge.script-test)] + (System/exit (+ fail error))))}}} diff --git a/close-swarm b/close-swarm new file mode 100755 index 0000000..a788cbd --- /dev/null +++ b/close-swarm @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: close-swarm [project-root]" >&2 + echo "Stops the SwarmForge swarm for the given project (default: current directory)." >&2 + exit 1 +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage +fi + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "${1:-.}" && pwd)" +STATE_DIR="$PROJECT_ROOT/.swarmforge" +SOCKET_FILE="$STATE_DIR/tmux-socket" +SESSIONS_FILE="$STATE_DIR/sessions.tsv" +WINDOW_IDS_FILE="$STATE_DIR/window-ids" +WINDOWS_STATE_FILE="$STATE_DIR/windows.tsv" + +if [[ ! -d "$STATE_DIR" ]]; then + echo "No SwarmForge swarm found at $PROJECT_ROOT (missing .swarmforge/)." >&2 + exit 1 +fi + +if [[ -x "$SELF_DIR/swarmforge/scripts/swarm-cleanup.sh" ]]; then + SCRIPT_DIR="$SELF_DIR/swarmforge/scripts" +elif [[ -x "$SELF_DIR/swarm-cleanup.sh" ]]; then + SCRIPT_DIR="$SELF_DIR" +elif [[ -x "$PROJECT_ROOT/swarmforge/scripts/swarm-cleanup.sh" ]]; then + SCRIPT_DIR="$PROJECT_ROOT/swarmforge/scripts" +else + echo "Could not find swarm-cleanup.sh relative to $SELF_DIR or $PROJECT_ROOT." >&2 + exit 1 +fi + +if [[ ! -f "$SOCKET_FILE" ]]; then + echo "No SwarmForge swarm found at $PROJECT_ROOT (missing .swarmforge/tmux-socket)." >&2 + exit 1 +fi + +TMUX_SOCKET="$(tr -d '[:space:]' < "$SOCKET_FILE")" +if [[ -z "$TMUX_SOCKET" ]]; then + echo "No SwarmForge swarm found at $PROJECT_ROOT (empty tmux socket)." >&2 + exit 1 +fi + +sessions=() +if [[ -f "$SESSIONS_FILE" ]]; then + while IFS=$'\t' read -r _index _role session _rest; do + [[ -n "${session:-}" ]] || continue + sessions+=("$session") + done < "$SESSIONS_FILE" +fi + +if (( ${#sessions[@]} == 0 )) && [[ -f "$WINDOWS_STATE_FILE" ]]; then + while IFS=$'\t' read -r _index _window_id session _title; do + [[ -n "${session:-}" ]] || continue + sessions+=("$session") + done < "$WINDOWS_STATE_FILE" +fi + +if (( ${#sessions[@]} == 0 )) && [[ -S "$TMUX_SOCKET" ]]; then + while IFS= read -r session; do + [[ -n "$session" ]] || continue + sessions+=("$session") + done < <(tmux -S "$TMUX_SOCKET" list-sessions -F '#{session_name}' 2>/dev/null || true) +fi + +if [[ -n "${SWARMFORGE_TERMINAL:-}" && -z "${SWARMFORGE_TERMINAL_BACKEND:-}" ]]; then + export SWARMFORGE_TERMINAL_BACKEND="$SWARMFORGE_TERMINAL" +fi + +"$SCRIPT_DIR/swarm-cleanup.sh" "$TMUX_SOCKET" "$WINDOW_IDS_FILE" "${sessions[@]+${sessions[@]}}" diff --git a/doc/plan-swarmforge-dir.md b/doc/plan-swarmforge-dir.md new file mode 100644 index 0000000..102a93d --- /dev/null +++ b/doc/plan-swarmforge-dir.md @@ -0,0 +1,97 @@ +# Plan: Option B — `SWARMFORGE_DIR` (external machinery, per-project config) + +> **Status**: pending implementation — working document to resume later. +> **Context**: came out of the trial run with `saas-prototype` (see the fork README). +> The goal is that projects do not carry SwarmForge code, only their configuration. + +## 1. Goal + +The project stops carrying SwarmForge **code** (scripts) and **shared rules** +(articles, default roles). It only keeps its **own configuration** (`swarmforge.conf` + +`project.prompt` + overrides). The fork (or any shared location via `SWARMFORGE_DIR`) +is the sole source of the machinery. With `SWARMFORGE_DIR` unset, **everything still works +as today** (full backward compatibility). + +## 2. Design: where each thing lives + +``` +SWARMFORGE_DIR (base, e.g. ~/.local/share/swarmforge = fork clone) +├── swarm/ + swarmforge/scripts/ ← launcher, daemon, helpers, adapters +├── swarmforge/roles/*.prompt ← default roles +└── swarmforge/constitution/articles/ ← engineering, handoffs, workflow (shared) + +PROJECT +└── swarmforge/ ← "thin": ONLY per-project material + ├── swarmforge.conf ← project roles + models + └── constitution/articles/project.prompt (+ local-*.prompt, role overrides if any) +``` + +**Merge rule**: the project wins by name — if the project has +`roles/cleaner.prompt`, that takes precedence; otherwise the base one is used. + +## 3. Code changes (file by file) + +| File | Change | Why | +|---|---|---| +| `swarmforge.bb` → `context` | Add `:base-dir` = `(or (System/getenv "SWARMFORGE_DIR") (fs/path working-dir "swarmforge"))`; keep `:swarm-forge-dir` = `working-dir/swarmforge` (project config) | Shared base vs. per-project config | +| `swarmforge.bb` → `parse-config` | `roles-dir` = per-role lookup: `project/roles/.prompt` if it exists, else `base/roles/.prompt` | Role overrides | +| `swarmforge.bb` → new `sync-shared-config!` | For each worktree **and for master**: copy from `base` the shared articles and default roles into the destination `swarmforge/` **only if missing** (project ones win); scripts as today | The agent reads `swarmforge/constitution.prompt` relative to its cwd — after sync, the merged view is there | +| `swarmforge.bb` → `prepare-workspace!` / setup | When syncing on master (project root), add derived files (shared articles) to `.gitignore` so the project's git stays clean | Shared articles are generated at startup, not committed | +| `write-agent-instruction-file!` | **No changes** | Relative paths still work because sync merges the view into each worktree | +| `check-helper-scripts!` | **No changes** (validates `script-dir`, which points at the base) | — | +| `handoffd.bb`, helpers, adapters | **No changes** | Already resolve the project via git (`roles.tsv`) | +| `swarm` wrapper | Small install/docs tweak: when installed globally, it runs the base `swarmforge.sh`; the download block remains for first-time setup | Global install | + +**Estimated total**: ~40-60 new/modified lines in `swarmforge.bb` + docs. Nothing else. + +## 4. User setup (once) + +```bash +# 1. Install the machinery once +git clone https://github.com/pablo-io/swarm-forge ~/.local/share/swarmforge +ln -s ~/.local/share/swarmforge/swarm ~/.local/bin/swarm +export SWARMFORGE_DIR=~/.local/share/swarmforge # (in your .bashrc) + +# 2. In any project: create ONLY the config +mkdir -p swarmforge/constitution/articles +# swarmforge.conf + project.prompt (+ local-* / overrides if applicable) + +# 3. Run +cd /my/project && swarm +``` + +## 5. Test plan + +1. **`bb test`** — the existing suite (24 tests) must pass with no semantic changes. +2. **Mode B**: minimal project with only conf + `project.prompt` → launch with `SWARMFORGE_DIR` + → verify: worktrees with the merged view (shared articles + roles + scripts), + functional master, clean startup. +3. **Handoff smoke**: a real end-to-end handoff (like the `saas-prototype` run) + under mode B. +4. **Backward compatibility**: `saas-prototype` (with full `swarmforge/`) without the env var → + must keep working the same. +5. **Sync**: change a script in the fork (e.g. a fix) → it is reflected without copying + anything into the project. + +## 6. Optional migration of `saas-prototype` (after validating B) + +- Thin its `swarmforge/`: delete `scripts/`, `roles/`, and shared articles; leave + `swarmforge.conf` + `project.prompt` (with the design rules). +- Re-copy updated prompts from the fork (`architect.prompt` with the written-report + rule) — now via the base, not manual copy. + +## 7. Risks / open decisions + +- **Shared articles synced onto master** will be gitignored (derived) — if someone wants + to version them explicitly, they can commit them (sync does not overwrite existing ones). +- **`roles.tsv` and state** stay under the project's `.swarmforge/` (gitignored) — unchanged. +- Agent instructions remain relative to the worktree — key to the design + (zero protocol changes). + +## 8. Suggested implementation order + +1. `context` + `parse-config` (base dir + role lookup) +2. `sync-shared-config!` + gitignore for derived files +3. `bb test` +4. Mode B trial with a minimal project +5. Doc in the fork README ("SWARMFORGE_DIR mode" section) diff --git a/doc/quality-level-proposal.md b/doc/quality-level-proposal.md new file mode 100644 index 0000000..5051887 --- /dev/null +++ b/doc/quality-level-proposal.md @@ -0,0 +1,154 @@ +# Proposal: Configurable quality level per project + +> **Status**: proposal pending implementation — working document. +> **Origin**: evaluation of the `saas-prototype` run (see the project's `REPORT.md`): fixed +> gates (CRAP≤6, mutation run, DRY, soft Gherkin) cost tokens that not every project needs. +> This proposal adds a configurable **quality axis** per project. +> **Note**: the two-pack review (section 5) narrows the proposal — the real value is depth +> within a pack, not replacing pack choice. + +## 1. The 3 levels + +| Level | Relative cost | Typical use | +|---|---|---| +| `minimal` | ~1x | Prototypes, spikes, throwaway code | +| `standard` | ~2x | Reasonable default for product features | +| `maximum` | ~3-4x | **Current rigor** — critical libraries, security/payments, code consumed by others | + +`maximum` = what the pipeline already does today (nothing beyond that for now). + +## 2. Gates by level + +| Gate | minimal | standard | maximum (current) | +|---|---|---|---| +| TDD + unit tests | ✓ | ✓ | ✓ | +| Acceptance Gherkin | optional | ✓ (without full APS pipeline) | ✓ full APS pipeline | +| CRAP | — | improve what is reasonable, no hard gate | **≤6** | +| DRY | — | reduce reasonable duplication | tooling, strict | +| Mutation scan + split >100 sites | — | ✓ (count only — cheap) | ✓ | +| Full mutation run | — | — | ✓ differential, kill non-equivalents | +| Soft Gherkin mutation | — | — | ✓ | +| Property tests | — | — | support | +| Written report | — | optional | ✓ | + +## 3. Role responsibility by level (four-pack) + +| Role | minimal | standard | maximum | +|---|---|---|---| +| **specifier** | Scoping + human gate only (no mandatory Gherkin) | Gherkin ✓ | Gherkin + QA suite | +| **coder** | Implements with TDD — required | ✓ | ✓ | +| **refactorer** | No gates → **no real work** | Reasonable CRAP + DRY + mutation scan | full gates | +| **architect** | No gates → **no real work** | light structural review only | full gates | + +**Conclusion**: level and workflow are correlated. At `minimal`, refactorer/architect have no +gates to apply — configuring 4 roles would waste tokens with no benefit. + +## 4. Mechanism (prompts/articles only, zero code) + +``` +1. PROJECT (project.prompt): "## Quality Level → Quality level: standard" +2. SHARED (quality.prompt): the ON/OFF gate table by level +3. EACH ROLE PROMPT (one line): "Apply only the gates that are ON for + the project's level (see quality article)" +``` + +The agent reads the level in `project.prompt`, the table in `quality.prompt`, and its role +prompt tells it to apply only the ON gates → consistent interpretation across roles. + +The `quality.prompt` article also includes the **role mapping by level**: "at minimal, +configure only specifier+coder (2 windows in `swarmforge.conf`); at standard, add +refactorer; at maximum, all 4". + +**Honest nuance**: the adjustment is *prompt-soft* — agents follow the level by instruction. +Hard enforcement would need a `tools/quality-check` (validate level artifacts), an optional +later step. + +## 5. Review: does two-pack already solve part of this? + +**Result of reviewing two-pack's real scope (original project):** + +- **coder (two-pack)**: TDD + unit tests ONLY — explicitly excludes acceptance, Gherkin, IR, + Gherkin mutation, property tests, CRAP, DRY, and language mutation. +- **cleaner (two-pack, batch)**: coverage, **CRAP≤6**, **DRY**, structure/encapsulation/dependencies + and **mutation run on uncovered behavior** + tests to kill mutants. + +**two-pack quality profile**: unit tests ✓ · CRAP≤6 ✓ · DRY ✓ · mutation run ✓ · +structure ✓ (inside cleaner) · acceptance/Gherkin ✗ · property ✗ · separate QA ✗. + +### Conclusion + +1. **two-pack is NOT `minimal`**: it keeps the hard hardening gates (CRAP≤6, DRY, mutation + run). It is "full hardening without specification" — not the cheap option on the depth + axis. +2. **Packs already encode a quality axis**: which gates EXIST (two-pack: no spec; + four-pack: spec + architecture; six-pack: + hardender + QA). +3. **The level axis adds what packs do NOT cover**: the DEPTH of each active gate + (CRAP≤6 vs "improve reasonably"; mutation run vs scan-only; soft Gherkin on/off). +4. **Practical implication**: for "cheap", choosing two-pack already drops the expensive + layers (spec/architecture) — the main cost lever is the pack. Level is for scaling depth + WITHIN a pack (e.g. two-pack without mutation run, four-pack without Gherkin mutation). + The run confirmed it: the main waste was four-pack for a login form, not gate depth. + +**Verdict**: the level proposal remains valid but is **narrower** than it first seemed: its +real value is depth within a pack, not replacing pack choice. Possible simplification: start +with only two levels (standard = current, light = no mutation run or Gherkin mutation) and +let pack choice do the rest. + +## 6. Analysis: spec vs hardening priority (the critique of two-pack) + +**two-pack's logic**: TDD already specifies behavior at the unit-test level; Gherkin is a +second layer (reviewable contract + end-to-end acceptance) that is expensive (APS pipeline); +hardening gates (CRAP≤6, DRY, mutation run) are the code quality floor. + +**The critique (valid)**: for a small task the priority is inverted — mutation run is +expensive and protects code that may be thrown away in a prototype; cheap spec ensures the +RIGHT thing is built. A well-hardened but wrong feature is still wrong. Logical order: +first WHAT (spec), then HOW (gates). + +| | two-pack | spec-first variant (proposal) | +|---|---|---| +| Base spec | unit tests (TDD) | Light Gherkin (reviewable contract + human approval) + TDD | +| Code protection | CRAP≤6 + DRY + **mutation run** | Reasonable CRAP/DRY, **no mutation run** | +| Cost | ~2-3x | ~1.5-2x | +| Risk covered | dirty/unchangeable code | **building the wrong thing** | + +**The gap it reveals**: two-pack assumes Gherkin comes with the full APS pipeline cost +(parser + entrypoint generator + runtime + step handlers). It offers no "spec-lite" variant: +write Gherkin as a reviewable contract without building the pipeline or running mutation. + +**Refinement of the `light` level**: + +> `light` = Gherkin written as contract + human approval + TDD + reasonable CRAP/DRY — +> **no APS pipeline, no mutation run, no Gherkin mutation, no property tests**. + +This variant covers the most important risk (is it the right thing? does a human approve?) +at lower cost than "mutation run without spec". + +## 7. Spec-light: Gherkin or other alternatives? + +**Comparison of options for a cheap, reviewable contract:** + +| Option | Cost | Human pre-code contract | Real enforcement | Risk | Upgrade to spec-full | +|---|---|---|---|---|---| +| TDD tests as spec (two-pack) | ~1x | ❌ | ✅ | user sees behavior at the end | — | +| Prose criteria (markdown) | ~1x | ✅ imprecise | ❌ | ambiguity | rewrite | +| **Gherkin written-only (light)** | ~1.5x | ✅ precise | ❌ | **spec drift** | ✅ zero rewrite | +| Given/When/Then scenarios in markdown | ~1x | ✅ | ❌ | no standard format | medium rewrite | + +**Key point**: in light, real enforcement comes from the coder's TDD — it turns each approved +scenario into unit tests. Gherkin remains a human contract + guide, not verification. +**Spec drift** risk is mitigated by a light workflow rule: *"the coder maps each approved +scenario to unit tests; the handoff/report confirms the scenario→tests mapping"*. + +**Recommendation**: in four-pack, light = natural degradation — the specifier already writes +Gherkin and asks for approval; the back half of the pipeline is cut (the coder does not build +entrypoint generator/runtime/step handlers, implements with TDD mapping scenarios). If you +later scale to spec-full, the `.feature` files are already there — you only build the pipeline +around them. + +**Recommended cheap add-on**: in light, the coder runs `gherkin-parser` ONLY to validate that +the spec parses (seconds, no pipeline build) — prevents broken Gherkin syntax from passing +as a contract. + +**When to choose each**: never going to scale → prose or TDD-only; may scale → Gherkin +written-only (the format IS the upgrade path); human must approve before coding → Gherkin. diff --git a/doc/sf-handoff-protocol.md b/doc/sf-handoff-protocol.md new file mode 100644 index 0000000..916e912 --- /dev/null +++ b/doc/sf-handoff-protocol.md @@ -0,0 +1,271 @@ +# SwarmForge — Handoff protocol and deterministic pipeline + +> Working document complementary to `swarmforge.md`. +> Example based on the full `six-pack` workflow (specifier → coder → cleaner → architect → hardender → QA). + +--- + +## 1. Handoff semantics (full example) + +**Task**: *"Implement a shopping cart with tax calculation"* → stable task name: **`cart-tax`**. That name travels the entire chain unchanged. + +### 1.1 The message contract + +Only **two message types** exist, and only the headers the agent may write: + +```text +type: git_handoff → "I committed work; merge and process it" +to: coder +priority: 50 → 00 = urgent · 50 = normal · 99 = low +task: cart-tax → stable name that travels the chain +commit: 3f9a2c1d7e → canonical 10-hex hash (the gate validates and canonicalizes it) +``` + +```text +type: note → short message (only if the constitution/role authorizes it) +to: architect +priority: 70 +message: <1 line, max 80 chars> +``` + +Agents **never write the payload or reserved headers** (`id`, `from`, `role`, `recipient`, `created_at`, `enqueued_at`…): the tool generates all of that. + +### 1.2 The specifier opens the chain + +The specifier talks with you, writes `features/cart.feature` (Gherkin) + the end-to-end QA suite, and **asks for your explicit approval**. Only after your OK does it commit and write its draft: + +```text +type: git_handoff +to: coder +priority: 50 +task: cart-tax +commit: 3f9a2c1d7e +``` + +It runs `swarm_handoff.sh draft` → the **validation gate** does 4 checks: `coder` is a known role, `50` is a valid priority, the commit **resolves to exactly one object and is a commit** (via `git rev-parse --disambiguate`), and there are no reserved fields or agent-written body. It generates the payload and installs it atomically in the outbox: + +```text +50_20260710T120000Z_000042_from_specifier_to_coder.handoff +``` + +The **daemon** (1 s polling) copies the file to the coder's `inbox/new/` **adding delivery headers**, and wakes the coder by typing into its tmux pane: *"You have new handoff mail. If idle, run ready_for_next.sh."* + Enter. + +The delivered file (this is what the coder sees): + +```text +id: 20260710T120000Z_000042_from_specifier +from: specifier +to: coder +recipient: coder ← added by the daemon (per-recipient copy) +priority: 50 +type: git_handoff +role: specifier +task: cart-tax +commit: 3f9a2c1d7e +created_at: 2026-07-10T12:00:00Z +enqueued_at: 2026-07-10T12:00:01Z ← added by the daemon + +Re-read your role and constitution. + +merge_and_process specifier 3f9a2c1d7e +``` + +### 1.3 The coder consumes the task + +The coder runs `ready_for_next.sh` → the helper moves the file from `inbox/new/` to `inbox/in_process/`, **adds `dequeued_at`**, and prints: + +```text +TASK: .swarmforge/handoffs/inbox/in_process/50_..._from_specifier_to_coder.handoff +FROM: specifier +TYPE: git_handoff +PRIORITY: 50 +TASK_NAME: cart-tax +PAYLOAD: +Re-read your role and constitution. + +merge_and_process specifier 3f9a2c1d7e +``` + +The coder does `merge_and_process specifier 3f9a2c1d7e` (merge of the specification commit), applies **TDD** (unit tests first, then implementation), runs the acceptance tests generated from the Gherkin, commits with byline (*"Implement cart tax" — `By coder.`*) and **forwards along the chain** with the same `task: cart-tax` and its new commit. Key rule: **an intermediate role ALWAYS forwards**, no matter what (even if the change is format-only). + +### 1.4 The cleaner in batch mode + +The cleaner is configured in `swarmforge.conf` with `batch`. If 3 handoffs arrive from the coder at the same priority, `ready_for_next_batch.sh` groups them: + +```text +BATCH: .swarmforge/handoffs/inbox/in_process/batch_20260710T130000Z_000051 +COUNT: 3 +PRIORITY: 50 +BATCH_ITEM: 1 → TASK_NAME: cart-tax ... +BATCH_ITEM: 2 → TASK_NAME: user-auth ... +BATCH_ITEM: 3 → TASK_NAME: cart-coupon ... +``` + +It processes all 3 as **one cleanup pass**: coverage, CRAP ≤ 6, DRY, mutation site scan (split files with >100 sites), acceptance + unit tests, commits, and forwards **once** to the architect. + +### 1.5 The chain continues (architect → hardender → QA) + +Each with the same mechanics: `ready_for_next.sh` (task or batch) → process its gate → verify → commit with byline → forward along the chain with `task: cart-tax` preserved. + +### 1.6 QA closes: the "terminal broadcast" + +When QA verifies everything (e2e UI suite, commit/manifest consistency, final CRAP/DRY), it commits and sends **a single handoff to multiple recipients** with `priority: 00`: + +```text +type: git_handoff +to: specifier,coder,cleaner,architect,hardender +priority: 00 +task: cart-tax +commit: b4d8e2f1a0 +``` + +This is the **exception to the forwarding rule**: each recipient does `merge_and_process QA b4d8e2f1a0`, runs its tests, and **does NOT forward**. The specifier, on receiving the broadcast, merges and asks you for the next feature. Chain closed. + +### 1.7 Each task's state machine + +```text +inbox/new/ ──ready_for_next──► inbox/in_process/ ──done_with_current──► inbox/completed/ + (daemon delivery) (+dequeued_at) (+completed_at) +``` + +- `done_with_current.sh` **picks up the next task or batch automatically** if there is a queue → agents do not sit idle. +- If a wake-up arrives while the agent is working → **it is ignored**; the queue is not lost because state lives in files. +- Swarm restart → agents re-run `ready_for_next.sh` and resume from `in_process`. + +--- + +## 2. Rules for a deterministic pipeline + +Determinism does not come from one place: it comes from **four layers of rules** that reinforce each other. + +### 2.1 Layer 1 — Shared rules (constitution) + +**`workflow.prompt`** (work discipline): + +- Each role works **only in its assigned worktree/branch**; forbidden to diff/merge foreign branches except via explicit handoff. +- Every commit carries a byline: `By .` +- Temporary files under `./tmp/` of the worktree, not `/tmp`. +- If the expected git layout does not exist → **stop and report**, do not improvise. + +**`handoffs.prompt`** (protocol): + +- Only `git_handoff` and `note`; notes require explicit authorization. +- On ambiguity/contradiction → **stop and ask**, do not send notes. +- **Mandatory chain forwarding**: each intermediate role forwards to the next stage after completing, even if the change is non-functional (format, manifests, metadata). +- **Terminal broadcast = merge-only**: recipients of the final handoff do not forward. +- `task:` is preserved when forwarding; invent a stable name only for new work. +- Forbidden to edit/add/commit handoff runtime state. + +**`engineering.prompt`** (technical rules): + +- TDD: unit tests first, then minimal production to pass. +- Quality tools (mutation/CRAP/DRY/coverage) run only on **testable modules**; "environmentally unsuitable" modules remain as excluded adapters. +- Acceptance via `gherkin-parser` (APS) — forbidden to reimplement the parser. +- Local verification before each handoff; verification commands never concurrent with each other. +- Guardrails: do not edit mutation manifests by hand; do not commit unrelated artifacts. + +### 2.2 Layer 2 — Per-role rules (six-pack) + +| Role | Owns | **Does Not Own** (boundary) | Verification before handoff | Handoff obligation | +|---|---|---|---|---| +| **specifier** | Gherkin + acceptance criteria + e2e QA suite | Does not run mutation or quality tools | Tests if needed; **nothing more** | **Does not commit or forward without your approval**. After your OK: commit + handoff to coder with invented `task:` | +| **coder** | Implementation of approved slices with TDD | QA suite, mutation, CRAP/DRY, Gherkin mutation | Unit tests + acceptance tests | Commit + handoff to cleaner | +| **cleaner** (batch) | Cleanup preserving behavior: names, duplication, boundaries, coverage | Mutation tests, Gherkin mutation, **new behavior** | CRAP ≤ 6, DRY, mutation site scan, acceptance + unit | Commit + handoff to architect **before taking another task/batch** | +| **architect** (batch) | Structure, boundaries, dependency direction, mutation hardening, DRY, property tests | — (inherits the chain) | Per-file mutation (differential), DRY, property tests, Gherkin soft | Commit + handoff to hardender | +| **hardender** (batch) | Mutation hardening (kill survivors), Gherkin mutation, final CRAP/DRY | Specifier's e2e QA suite | Mutation → Gherkin soft → CRAP → DRY | Commit + handoff to QA | +| **QA** (batch) | Independent final verification, turn QA suite into executable scripts, e2e via UI | Mutation and Gherkin mutation | e2e UI suite, handoff/manifest consistency, CRAP/DRY | Commit + **broadcast priority 00 to all** (merge-only) | + +### 2.3 Layer 3 — Transport rules (the gate) + +- `swarm_handoff.sh` **rejects** drafts with: reserved fields, unknown roles, non-numeric priority (00–99), ambiguous or non-commit commits, `task` > 80 chars, agent-written bodies. The agent repairs and retries; nothing malformed enters the queue. +- Priorities: **50** = normal chain progress, **00** = terminal broadcast / urgent follow-up work. The queue orders by `priority_timestamp_sequence`, so order is **deterministic even if they arrive in the same second**. +- `batch` roles consume **all equal-priority handoffs as one unit** → cleaner/reviewer does not interrupt its pass for each delivery. +- Agents **do not talk to tmux**: the daemon is the only one with socket access; agents only write files to their outbox. Control channel and state channel are separated. + +### 2.4 Layer 4 — State rules (the queue as a state machine) + +- `new → in_process → completed` with audit timestamps (`enqueued_at`, `dequeued_at`, `completed_at`). +- **Resumption**: state lives in files, not memory — you restart the swarm and `ready_for_next.sh` resumes from `in_process`. +- `done_with_current.sh` **chains the next task** automatically → the pipeline advances without human intervention between gates. + +### 2.5 Where determinism comes from (summary) + +1. **Closed message types** (2) and **strict validation gate** → nothing ambiguous enters the system. +2. **Mandatory chain forwarding** + **merge-only broadcast** → processing order is always the same, with no skips or loops. +3. **Ownership boundaries** ("Does Not Own") → each agent only touches its own work; nobody steps on another's (coder does not do mutation; cleaner does not introduce behavior). +4. **Mandatory verification before each handoff** → a handoff only exists if its gate passed. +5. **Worktree isolation** → each role sees only its branch; merge happens explicitly via `merge_and_process` at handoff time. +6. **Stable task name + priority + sequence** → full traceability: you can follow `cart-tax` commit by commit through the whole chain. + +--- + +## 3. Diagrams + +### 3.1 Full pipeline (six roles, `six-pack`) + +```mermaid +sequenceDiagram + autonumber + participant U as User + participant S as Specifier + participant C as Coder + participant CL as Cleaner (batch) + participant A as Architect (batch) + participant H as Hardender (batch) + participant Q as QA (batch) + + U->>S: Implement cart with tax + S->>U: Gherkin + e2e QA suite (asks approval) + U-->>S: Approved + S->>S: commit spec + draft (type/to/priority/task/commit) + S->>S: swarm_handoff.sh → outbox (gate: canonical commit) + Note over S,C: daemon delivers to coder inbox/new + tmux wake-up + C->>C: ready_for_next.sh → in_process + TASK cart-tax + C->>C: merge_and_process specifier `` + TDD + acceptance + C->>C: commit + byline + forward (same task) + Note over C,CL: daemon delivers (several equal-priority handoffs) + CL->>CL: ready_for_next.sh → BATCH (N items) + CL->>CL: CRAP ≤ 6 + DRY + mutation scan + tests + CL->>CL: commit + forward to architect + A->>A: structure + dependencies + differential mutation + DRY + A->>A: commit + forward to hardender + H->>H: mutation hardening + Gherkin soft + CRAP/DRY + H->>H: commit + forward to QA + Q->>Q: e2e UI suite + handoff consistency + Q->>Q: commit + broadcast priority 00 (merge-only) + Q-->>S: merge_and_process QA `` — no forward + S->>U: Next feature? +``` + +### 3.2 Handoff chain and priorities + +```mermaid +flowchart LR + U[User] -->|"intent"| S[Specifier] + S -->|"git_handoff p50 · stable task"| C[Coder] + C -->|"git_handoff p50"| CL[Cleaner · batch] + CL -->|"git_handoff p50"| A[Architect · batch] + A -->|"git_handoff p50"| H[Hardender · batch] + H -->|"git_handoff p50"| Q[QA · batch] + Q -->|"git_handoff p00 · broadcast merge-only"| S + S -.->|"human approval"| U +``` + +### 3.3 Task lifecycle + +```mermaid +stateDiagram-v2 + [*] --> new: daemon delivers .handoff + new --> in_process: ready_for_next.sh (dequeued_at) + in_process --> completed: done_with_current.sh (completed_at) + in_process --> in_process: next queued task or batch + new --> [*]: NO_TASK (empty queue) +``` + +--- + +## 4. Mermaid syntax notes (validated with v11.13.0) + +- In `sequenceDiagram` messages do not use `<`/`>` entities — use backticks: `` `` ``. +- In `flowchart` labels do not use escaped double quotes (`\"`) — use inner single quotes or plain text. +- `
` does work inside sequence messages and labels. diff --git a/doc/swarmforge.md b/doc/swarmforge.md new file mode 100644 index 0000000..66a94e1 --- /dev/null +++ b/doc/swarmforge.md @@ -0,0 +1,308 @@ +# SwarmForge — Description, protocol, and migration requirements + +> Working document. Project source: https://github.com/unclebob/swarm-forge +> Goal: understand SwarmForge's architecture and evaluate adapting it to **pi** as the agent, on **Linux**, with **DeepSeek / GLM / Qwen** models. + +--- + +## 1. Project description + +**SwarmForge** is a **tmux**-based agent orchestration platform that turns a swarm of AI agents into a coordinated software engineering team. It was created by Robert C. Martin and applies his own engineering discipline (TDD, Gherkin/acceptance testing, mutation testing, CRAP/DRY analysis) to the problem of coordinating agents. + +Core idea: **each agent lives in its own git worktree and its own tmux session**, and agents communicate via a **file-based handoff protocol** delivered by a daemon. There are no direct messages between agents and no direct access to the tmux socket by them. + +### Branch structure + +| Branch | Description | Roles | +|---|---|---| +| `main` | **Documentary**: shared operational scripts + default constitution articles | — | +| `two-pack` | Fast backend workflow (TDD + hardening, no Gherkin) | `coder` → `cleaner` → `coder` | +| `four-pack` | Compact workflow with Gherkin specification | `specifier` → `coder` → `refactorer` → `architect` → `specifier` | +| `six-pack` | Full workflow with all quality gates separated | `specifier` → `coder` → `cleaner` → `architect` → `hardender` → `QA` → end | + +Each executable branch contains the project config: `swarmforge.conf` (topology), `roles/.prompt` (per-role prompts) and `constitution.prompt` + articles (shared rules). On startup, the `./swarm` wrapper downloads the shared operational scripts from `main` (first time only) and launches the orchestrator. + +### How it works at a high level + +1. **Declarative configuration**: `swarmforge.conf` defines the swarm window by window: + ``` + window [task|batch] [extra-args...] + ``` +2. **Launcher** (`swarmforge.bb`, Babashka): validates the config, initializes the git repo if needed, creates a **worktree per role** under `.worktrees/`, creates a **tmux session per role** on a project-owned socket and launches each agent with its initial prompt. +3. **Agents**: each runs as an interactive TUI in its tmux pane, inside its worktree, with the handoff scripts on its `PATH`. +4. **Daemon** (`handoffd.bb`): owner of the tmux socket. Watches agent outboxes, delivers handoffs to recipient inboxes and wakes agents with a message typed into their pane. +5. **Handoff protocol**: agents create validated drafts, receive them as tasks or batches (`task`/`batch`), and report completion with `done_with_current.sh`. +6. **Optional viewer**: terminal adapters (`terminal-adapters/*.sh`) open one window per role for real-time observation, with a watchdog that reopens closed windows without losing agent state. + +### Key features + +- **Config-driven topology**: swarm shape comes from `swarmforge.conf`, not from code. +- **Per-project roles**: `swarmforge/roles/.prompt` per branch/backlog. +- **Layered constitution**: `constitution.prompt` directs agents to read articles under `swarmforge/constitution/articles/` (shared engineering, handoff and workflow rules + local per-branch rules). +- **Per-role backends**: each role can use a different agent CLI (`claude`, `codex`, `copilot`, `grok`). +- **Observable**: one terminal window per role, or headless in tmux. +- **Self-hosted and light**: only needs tmux, git, zsh and Babashka; all state lives in `.swarmforge/` inside the project. +- **Operational robustness**: host sleep prevention (`caffeinate`/`systemd-inhibit`), task resumption after restart, file-based audit (`new` → `in_process` → `completed`). + +--- + +## 2. Handoff protocol (summary) + +The protocol separates **state** (files on the filesystem, durable and auditable) from **control** (tmux, only for notification and liveness). + +### Messages + +Only two types, both strictly validated: + +``` +type: git_handoff type: note +to: [,...] to: [,...] +priority: NN (00-99) priority: NN (00-99) +task: message: <1 line, max 80 chars> +commit: <10 hex> +``` + +- `git_handoff`: the sender has committed work; the receiver does `merge_and_process `. +- `note`: short message; only when the constitution or role explicitly authorizes it. + +### Flow + +1. The agent commits and writes a **draft** with headers only. +2. `swarm_handoff.sh` is the **validation gate**: rejects reserved fields, unknown roles, invalid priorities, ambiguous commits (canonicalizes the hash with `git rev-parse --disambiguate`) and bodies that are not generated. +3. The helper generates the payload (`id`, `from`, `role`, `task`, `created_at`, body) and installs it atomically in `outbox/`. +4. The **daemon** polls (1s), copies the handoff to each recipient's `inbox/new/` (adding `recipient` and `enqueued_at`) and wakes the receiver. +5. The receiver runs `ready_for_next.sh` → moves to `inbox/in_process/` (adds `dequeued_at`) and prints `TASK:`/`BATCH:` with the payload. +6. On completion, `done_with_current.sh` moves to `inbox/completed/` (adds `completed_at`) and picks up the next task if one exists. +7. The daemon moves the sender's original to `sent/` or `failed/`. + +### Wake-up (control plane) + +The daemon "wakes" an agent by typing into its tmux pane: + +``` +tmux send-keys -t -l "You have new handoff mail. If idle, run ready_for_next.sh." +tmux send-keys -t C-m # Enter +tmux send-keys -t C-j # robustness LF +``` + +The agent receives it as a user message. Protocol rules: if it is already working, **ignore the wake-up**; `done_with_current.sh` picks up the next task when finished. In practice, an agent with a message queue (like pi) enqueues the wake-up and delivers it when the turn ends. + +### Chain rules + +- Intermediate roles **always forward** a `git_handoff` to the next role in the chain, no matter what (even if the change is non-functional). +- The final handoff of the chain (broadcast) is **merge-only**: recipients merge and do not forward. +- Task names (`task:`) are preserved along the chain. + +--- + +## 3. Migration requirements + +### 3.1 Agent contract (necessary condition) + +SwarmForge requires the agent to be a **long-lived interactive process in a tmux pane** that satisfies: + +1. **Interactive CLI (TUI/REPL)** that keeps running — wake-ups arrive as typed text + Enter; a one-shot CLI cannot receive work. +2. **Initial prompt via command line** (or injectable via `tmux send-keys` after startup). +3. **Work in the worktree directory** (`cd && ...`). +4. **Ability to run commands** (the helpers `swarm_handoff.sh`, `ready_for_next.sh`, `done_with_current.sh` are shell/bb on `PATH` — they are model-agnostic). + +Everything else in the protocol (handoffs, worktrees, daemon, wake-ups, watchdog) **does not know about the model**: the only integration point is the launch arm in `swarmforge.bb` and the validated backend list in `parse-config`. + +### 3.2 Validation: pi as agent + +**Fits out of the box.** Verified in docs and installed binary: + +- `pi ""` starts the TUI, **sends the initial message and stays interactive** (confirmed in `dist/modes/interactive/interactive-mode.js`). +- **tmux officially supported** (`docs/tmux.md`). Recommendation: tmux ≥ 3.5 with `extended-keys-format csi-u` for modified keys; the basic protocol (Enter) works with any version. +- **Compatible wake-up**: in pi `Enter` = send, `Ctrl+J` = new line (the daemon's `C-j` is harmless). +- **Message queue**: a message typed while pi is working is **enqueued and delivered when the turn ends** — ideal for the protocol's wake-up semantics. +- **Sessions**: `pi -c` (continue), `--session`, `--name "SwarmForge "` (session name). + +Operational requirements with pi: + +| Requirement | Detail | +|---|---| +| Trust prompt | pi asks on startup in a new project and **would block the agent**. Use `-a/--approve` in the launch arm or pre-seed `~/.pi/agent/trust.json`. | +| Fixed model | Use `--model /` so each role does not start at the login selector. | +| Runtime | Node.js ≥ 22 (npm install) or standalone script; Linux supported natively. | + +Proposed launch arm in `swarmforge.bb`: + +```clojure +"pi" (str "pi -a --name " (sq (str "SwarmForge " display)) + " --model " (sq model) " " + (extra-args-prefix row) + "\"$(cat " (sq (str prompt-file)) ")\"") +``` + +### 3.3 Validation: opencode as agent + +**Fits with a mandatory adaptation.** Verified on the real v1.18.15 binary and in source: + +- `opencode` (no args) starts the persistent **interactive TUI**. +- ⚠️ The TUI **does not accept an initial message via CLI**: `--prompt` in TUI mode calls a Node `rl.question` (waits for stdin input); only `--mini --prompt` sends it as a message, but with `interactive: false` (runs and exits). `opencode run ""` is **headless one-shot** — not usable as a swarm agent. +- **Solution**: launch the TUI (`opencode --auto`) and **inject the prompt with `tmux send-keys`** after startup — the same mechanism the daemon already uses to wake. About ~10 lines in `launch-role!` (launch → sleep → `send-keys -l "$(cat prompt)"` + Enter). + +``` +opencode --auto -m / # in the role's tmux session +# after ~2s: +tmux send-keys -t -l "" ; tmux send-keys -t C-m +``` + +Operational requirements with opencode: + +| Requirement | Detail | +|---|---| +| Permissions | `--auto` (auto-approve; also the hidden aliases `--yolo` / `--dangerously-skip-permissions`) — equivalent to the swarm's autonomous mode. | +| Sessions | `-c/--continue`, `-s/--session` for the restart flow ("on restart, run ready_for_next.sh"). | +| Runtime | Static binary (npm `opencode-ai` or GitHub release); Linux supported. | +| Future | `opencode serve` + `attach`/SDK/ACP would allow a native message queue without keyboard wake-ups (would require changing the architecture, not adapting it). | + +### 3.4 Models: DeepSeek / GLM / Qwen + +| Model | pi | opencode | +|---|---|---| +| **DeepSeek** | **Native**: `DEEPSEEK_API_KEY`, provider `deepseek`, `--model deepseek/...` | **Native** in catalog (`models.dev`): `deepseek-*` | +| **Qwen** | **Native**: `QWEN_TOKEN_PLAN_API_KEY`, providers `qwen-token-plan` / `-individual` / `-cn` (China) | **Native**: `qwen3.x-*`, `alibaba-*/qwen*` | +| **GLM (Zhipu)** | **Not native**: needs a custom provider extension (OpenAI-compatible, `api: "openai-completions"`, `thinkingFormat: "zai"`) or OpenAI-compatible proxy | **Native**: `glm-4.x`/`glm-5.x` (`opencode-go/glm-*`, `alibaba-*/glm-*`) | + +Note: pi already implements the *thinking* formats of all three families (`thinkingFormat: "deepseek" | "zai" | "qwen"` in `docs/custom-provider.md`), which simplifies GLM integration: you only need to register the endpoint and models with that extension. + +### 3.5 Linux (runtime) + +| Requirement | Status | Detail | +|---|---|---| +| `zsh` | **Hard requirement** | Scripts use `#!/usr/bin/env zsh`. Arch: `pacman -S zsh`. | +| `tmux` | Required | Recommended ≥ 3.5 (pi with extended keys). | +| `git` | Required | Worktrees and commit protocol. | +| Babashka (`bb`) | Required | Launcher and all helpers are Babashka (cross-platform). | +| Node.js ≥ 22 | pi only | npm install of pi (or standalone script). | +| Terminal | **Headless works** | By default on Linux (no `osascript`/`wt.exe`) the launcher falls back to `none`: attaches the current shell to the first role's session and the rest stay detached (`tmux -S attach -t swarmforge-`). The swarm runs fully without windows. | +| Automatic windows (optional) | To build | Write a `terminal-adapters/wezterm.sh` (or kitty) for Linux: 5-function contract (~40 lines). WezTerm is the most scriptable (`wezterm cli`); Ghostty on Linux has no remote control. | +| Shutdown | Plan for | The `close-swarm` script lives on the `main` branch; executable branches do not carry it — copy it into the project or use shutdown by "closing the first window". | +| Sleep prevention | Works | `systemd-inhibit` on Linux (systemd running). Disable with `SWARMFORGE_PREVENT_SLEEP=0`. | + +### 3.6 Necessary code changes (minimal) + +In `swarmforge/scripts/swarmforge.bb` (the working branch, e.g. `four-pack`): + +1. **`parse-config`**: add the backend to the validated list, e.g. `#{"claude" "codex" "copilot" "grok" "pi"}`. +2. **`launch-command`**: add the new backend's arm (pi: section 3.2; opencode: section 3.3). +3. **`check-backend-dependencies!`**: no changes — already checks that the binary exists on `PATH`. + +In the project config: + +- `swarmforge.conf`: `window coder pi master` (or `opencode`), with `[task|batch]` and extra args per role. + +Optional depending on goal: + +- Shared constitution articles in `swarmforge/constitution/articles/` of the branch (the wrapper only *stages* them in `scripts/shared-articles/`; confirm agents read what the branch needs). +- Linux terminal adapter (section 3.5). +- `close-swarm` in the project. + +--- + +## 4. Architecture diagram + +```mermaid +flowchart TB + subgraph Config["Configuration (per project/branch)"] + CONF["swarmforge.conf
window role agent worktree [task|batch] [args]"] + ROLES["swarmforge/roles/<role>.prompt"] + CONST["swarmforge/constitution.prompt
+ constitution/articles/"] + end + + subgraph Launcher["Launcher — swarmforge.bb (Babashka)"] + PARSE["Validate config and prompts"] + WT["Git worktrees
.worktrees/<role> (branch per role)"] + TMUX["tmux sessions
swarmforge-<role> · project-owned socket"] + LAUNCH["send-keys: export SWARMFORGE_ROLE
+ PATH helpers + cd worktree
+ <agent> '$(cat prompt)'"] + end + + subgraph Swarm["Swarm (1 agent per role)"] + A1["Agent TUI
(tmux pane)"] + A2["Agent TUI
(tmux pane)"] + A3["Agent TUI
(tmux pane)"] + end + + subgraph State["Durable state — filesystem (.swarmforge/handoffs)"] + OUT["outbox/ · sent/ · failed/"] + IN["inbox/ new · in_process · completed"] + end + + subgraph Control["Control — daemon handoffd.bb"] + DAEMON["Poll outbox → deliver to inbox
→ wake-up via tmux send-keys"] + end + + subgraph Viewer["Viewer (optional)"] + ADAPT["terminal-adapters/*.sh"] + WATCH["swarm-window-watchdog"] + end + + CONF --> PARSE + ROLES --> PARSE + CONST --> PARSE + PARSE --> WT --> LAUNCH + PARSE --> TMUX --> LAUNCH + LAUNCH --> A1 & A2 & A3 + A1 & A2 & A3 -->|"helpers on PATH:
swarm_handoff.sh"| OUT + OUT --> DAEMON + DAEMON -->|"deliver .handoff"| IN + DAEMON -->|"wake-up: text + Enter"| A1 & A2 & A3 + A1 & A2 & A3 -->|"ready_for_next.sh
done_with_current.sh"| IN + A1 & A2 & A3 -->|"work (git)"| WT + A1 & A2 & A3 -->|"observe: tmux attach"| TMUX + TMUX --> ADAPT --> WATCH +``` + +## 5. Protocol diagram (one handoff cycle) + +```mermaid +sequenceDiagram + autonumber + participant S as Sender agent (e.g. coder) + participant V as swarm_handoff.sh (gate) + participant O as outbox/ (sender) + participant D as Daemon handoffd.bb + participant I as inbox/ (receiver) + participant R as Receiver agent (e.g. cleaner) + + Note over S: git commit (message with role byline) + S->>S: Write draft (type/to/priority/task/commit) + S->>V: swarm_handoff.sh `` + V->>V: Validate: known roles, priority 00-99,
canonical commit (10 hex, --disambiguate),
reserved fields, body forbidden + V->>O: Install generated .handoff (id, from, role,
task, created_at, merge_and_process payload) + V-->>S: HANDOFF QUEUED + O->>D: Poll (1 s) + D->>I: Copy to each recipient inbox/new/
+ recipient, enqueued_at headers + D->>R: tmux send-keys -l 'You have new handoff mail...'
+ C-m (Enter) + C-j (robustness) + Note over R: If busy → ignore (queue or next
done_with_current will pick it up) + R->>I: ready_for_next.sh → move to in_process/
+ dequeued_at header + I-->>R: TASK: `` / BATCH: `` + PAYLOAD + R->>R: merge_and_process `` ``
+ process the task in its worktree + R->>I: done_with_current.sh → completed/
+ completed_at header + I-->>R: Next task or NO_TASK + D->>O: Move original to sent/ (or failed/) +``` + +### Inbox task lifecycle + +```mermaid +stateDiagram-v2 + [*] --> new: daemon delivers .handoff + new --> in_process: ready_for_next.sh (dequeued_at) + in_process --> completed: done_with_current.sh (completed_at) + in_process --> in_process: next queued task + new --> [*]: NO_TASK (empty queue) + failed --> [*]: delivery error (sender outbox) +``` + +--- + +## 6. Executive summary + +1. **The architecture is model-agnostic**: the only integration point for a new backend is the launch arm + the validated backend list in `swarmforge.bb`; the handoff protocol, worktrees, daemon and wake-ups do not know about the agent. +2. **pi fits directly**: interactive initial message via CLI, tmux supported, message queue aligned with wake-up semantics, native DeepSeek/Qwen and GLM with a small extension. +3. **opencode fits with an adaptation**: the TUI does not accept an initial prompt via CLI → inject via `tmux send-keys` after startup (mechanism already in the system). Native DeepSeek/GLM/Qwen. +4. **Linux is a first-class citizen by design**: the swarm lives in tmux, not in windows; headless runs fully. Automatic windows are only an optional terminal adapter. +5. **Minimum requirements**: zsh + tmux (≥3.5 recommended) + git + Babashka + (Node.js for pi) + ~15 lines of changes in `swarmforge.bb` + provider config. diff --git a/psf-memo-client/.env.development b/psf-memo-client/.env.development new file mode 100644 index 0000000..152b214 --- /dev/null +++ b/psf-memo-client/.env.development @@ -0,0 +1,4 @@ +# psf-memo-db REST API base URL (no trailing slash). +# Used at build time by Create React App (must be prefixed with REACT_APP_). +#REACT_APP_MEMO_DB_URL=http://localhost:5021 +REACT_APP_MEMO_DB_URL=https://memo-api.fullstackcash.net diff --git a/psf-memo-client/.env.example b/psf-memo-client/.env.example new file mode 100644 index 0000000..ec1faa7 --- /dev/null +++ b/psf-memo-client/.env.example @@ -0,0 +1,3 @@ +# psf-memo-db REST API base URL (no trailing slash). +# Used at build time by Create React App (must be prefixed with REACT_APP_). +REACT_APP_MEMO_DB_URL=http://localhost:5021 diff --git a/psf-memo-client/.gitignore b/psf-memo-client/.gitignore new file mode 100644 index 0000000..861bf9c --- /dev/null +++ b/psf-memo-client/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +build/ +docs/ +tmp/ +target/ + +.gitsigners diff --git a/psf-memo-client/.on-save.json b/psf-memo-client/.on-save.json new file mode 100644 index 0000000..506a153 --- /dev/null +++ b/psf-memo-client/.on-save.json @@ -0,0 +1,8 @@ +[ + { + "srcDir": "", + "destDir": "", + "files": "**/*.js", + "command": "npm run lint" + } +] diff --git a/psf-memo-client/LICENSE.md b/psf-memo-client/LICENSE.md new file mode 100644 index 0000000..8ced85f --- /dev/null +++ b/psf-memo-client/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2025 Chris Troutner + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/psf-memo-client/PEDIGREE.md b/psf-memo-client/PEDIGREE.md new file mode 100644 index 0000000..2c3b11a --- /dev/null +++ b/psf-memo-client/PEDIGREE.md @@ -0,0 +1,3 @@ +# Pedigree + +This code repository is forked from [react-bootstrap-web3-spa](https://github.com/Permissionless-Software-Foundation/react-bootstrap-web3-spa), and any updates to that upstream repository are pulled into this repository. \ No newline at end of file diff --git a/psf-memo-client/README.md b/psf-memo-client/README.md new file mode 100644 index 0000000..cbee62f --- /dev/null +++ b/psf-memo-client/README.md @@ -0,0 +1,24 @@ +# psf-memo-client + +This is a web-based single page app (SPA) written in React. It provides non-custodial wallet features for the Bitcoin Cash blockchain, including support for SLP tokens and NFTs. + +This web wallet is forked from [bch-wallet-web3-spa](https://github.com/Permissionless-Software-Foundation/bch-wallet-web3-spa). It's had additional user interfaces added to it for interacting with the REST API provided by [psf-memo-db](https://github.com/Permissionless-Software-Foundation/psf-memo-db). + +## Installation +```bash +git clone https://github.com/Permissionless-Software-Foundation/psf-memo-client +cd bch-wallet-web3-spa +npm install +npm start +npm run build +``` + +## Support + +Have questions? Need help? Join our community support +[Telegram channel](https://t.me/bch_js_toolkit) + + +## License +[MIT](./LICENSE.md) +. diff --git a/psf-memo-client/acceptance/acceptance.js b/psf-memo-client/acceptance/acceptance.js new file mode 100644 index 0000000..c2ae8ef --- /dev/null +++ b/psf-memo-client/acceptance/acceptance.js @@ -0,0 +1,99 @@ +/* + Normal acceptance runner for psf-memo-client. + + Orchestrates the acceptance pipeline: + feature file -> bb gherkin-parser -> JSON IR -> acceptance entrypoint + generator -> generated test entry points -> node test runner + + It procures the latest Babashka APS tools from the Acceptance-Pipeline- + Specification repository on first use, then parses, generates, and runs every + Gherkin feature under specs/. + + Exit code 0 when all acceptance tests pass; non-zero otherwise. +*/ + +'use strict' + +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') + +const root = path.resolve(__dirname, '..') +const specsDir = path.join(root, 'specs') +const buildDir = path.join(root, 'build', 'acceptance') +const irDir = path.join(buildDir, 'ir') +const genDir = path.join(buildDir, 'generated') +const apsDir = path.join(root, 'tmp', 'aps-spec') + +function sh (cmd, args, opts = {}) { + return execFileSync(cmd, args, { + stdio: ['pipe', 'pipe', 'pipe'], + ...opts + }).toString() +} + +// Procure the latest APS tools if not already present in the worktree. +function ensureAps () { + if (fs.existsSync(apsDir)) return + fs.mkdirSync(path.dirname(apsDir), { recursive: true }) + sh('git', ['clone', '--depth', '1', + 'https://github.com/unclebob/Acceptance-Pipeline-Specification.git', apsDir]) +} + +function main () { + ensureAps() + + const features = fs + .readdirSync(specsDir) + .filter((f) => f.endsWith('.feature')) + .sort() + + if (features.length === 0) { + console.log('No feature files found under specs/.') + return + } + + fs.mkdirSync(irDir, { recursive: true }) + fs.mkdirSync(genDir, { recursive: true }) + + for (const featureFile of features) { + const base = featureFile.replace(/\.feature$/i, '') + const featurePath = path.join(specsDir, featureFile) + const irPath = path.join(irDir, `${base}.json`) + + // 1) Parse the feature to JSON IR using the Babashka APS gherkin-parser. + sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir }) + + // 2) Generate executable acceptance entry points from the IR. + sh('node', [path.join(root, 'acceptance', 'lib', 'generate.js'), irPath, genDir]) + } + + // 3) Run every generated acceptance test. + const tests = fs + .readdirSync(genDir) + .filter((f) => f.endsWith('.acceptance.test.js')) + .sort() + + let failures = 0 + for (const testFile of tests) { + try { + const out = sh('node', [path.join(genDir, testFile)]) + process.stdout.write(out) + console.log(`ACCEPTANCE PASS: ${testFile}`) + } catch (err) { + failures++ + process.stdout.write(err.stdout || '') + process.stderr.write(err.stderr || '') + console.error(`ACCEPTANCE FAIL: ${testFile}`) + } + } + + if (failures > 0) { + console.error(`ACCEPTANCE: ${failures} failing test file(s)`) + process.exit(1) + } else { + console.log(`ACCEPTANCE: all ${tests.length} generated test file(s) passed`) + } +} + +main() diff --git a/psf-memo-client/acceptance/lib/generate.js b/psf-memo-client/acceptance/lib/generate.js new file mode 100644 index 0000000..592049d --- /dev/null +++ b/psf-memo-client/acceptance/lib/generate.js @@ -0,0 +1,112 @@ +/* + Project-specific acceptance entrypoint generator. + + Reads parser JSON IR and writes executable generated test entry points plus + per-feature metadata. Generated tests delegate all step behavior to the + acceptance runtime and project step handlers. + + Usage: + node acceptance/lib/generate.js + + Exit codes: + 0 generation succeeded + 1 generation error + 2 wrong command usage +*/ + +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const crypto = require('node:crypto') + +// Convert a feature path to a strict lowercase-and-hyphen metadata filename. +function metadataName (featureName) { + const slug = featureName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return `${slug || 'feature'}.json` +} + +// Compute a stable relative require path from a generated file's directory to +// a target module, with a './' or '../' prefix for require(). +function relativeRequire (fromDir, targetFile) { + let rel = path.relative(fromDir, targetFile).replace(/\\/g, '/') + if (!rel.startsWith('.')) rel = `./${rel}` + return rel +} + +function main () { + const irArg = process.argv[2] + const outArg = process.argv[3] + + if (!irArg || !outArg) { + console.error('usage: acceptance-entrypoint-generator ') + process.exit(2) + } + + let ir + try { + ir = JSON.parse(fs.readFileSync(irArg, 'utf8')) + } catch (err) { + console.error(`Failed to read JSON IR "${irArg}": ${err.message}`) + process.exit(1) + } + + const genDir = outArg + fs.mkdirSync(genDir, { recursive: true }) + + const featureKey = path.basename(irArg).replace(/\.json$/i, '') + const testFile = path.join(genDir, `${featureKey}.acceptance.test.js`) + const relRuntime = relativeRequire(genDir, path.join(__dirname, 'runtime.js')) + + const body = `'use strict' +const { runFeature } = require('${relRuntime}') +const ir = ${JSON.stringify(ir, null, 2)} +async function main () { + const report = await runFeature(ir) + for (const r of report.results) { + console.log((r.status === 'passed' ? 'PASS ' : 'FAIL ') + r.name) + if (r.detail) console.log(' ' + r.detail) + } + if (report.failures > 0) { + console.error('ACCEPTANCE FAILURES: ' + report.failures + ' of ' + report.total) + process.exitCode = 1 + } +} +main().catch((err) => { console.error(err); process.exit(1) }) +` + + try { + fs.writeFileSync(testFile, body) + } catch (err) { + console.error(`Failed to write generated test "${testFile}": ${err.message}`) + process.exit(1) + } + + // Per-feature metadata with an implementation hash over generated files only. + const metaDir = path.join(genDir, 'metadata') + fs.mkdirSync(metaDir, { recursive: true }) + const hash = crypto + .createHash('sha256') + .update(fs.readFileSync(testFile)) + .digest('hex') + + const metadata = { + schema_version: 1, + feature_path: `${featureKey}.feature`, + ir_path: path.resolve(irArg), + implementation_hash: `sha256:${hash}`, + hash_scope: 'generated_files', + generated_files: [testFile] + } + fs.writeFileSync( + path.join(metaDir, metadataName(featureKey)), + JSON.stringify(metadata, null, 2) + ) + + process.exit(0) +} + +main() diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js new file mode 100644 index 0000000..e2eaba8 --- /dev/null +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -0,0 +1,814 @@ +/* + Project step handlers for the psf-memo-client acceptance pipeline. + + These handlers connect Gherkin step text to real project behavior + (src/services/memo-post.js, src/services/new-post.js, src/services/memo-reply.js, + src/services/reply-thread-page.js, src/services/memo-set-name.js, and + src/services/set-name-page.js), driving them through small injected adapters + (a fake wallet, a fake feed, a fake thread, and a fake navigator) so the + acceptance run is deterministic and offline. + + Regex matching with placeholder-name capture is the default style: a single + handler pattern captures the placeholder name (e.g. ) and fetches + the example value from the scenario example store. + + The handlers serve specs/post-memo.feature, specs/memo-new.feature, + specs/reply-memo.feature, and specs/set-name.feature, whose wording differs + but which share the same underlying Memo action/page-controller behavior. +*/ + +'use strict' + +const MemoPost = require('../../src/services/memo-post') +const NewPostPage = require('../../src/services/new-post') +const MemoReply = require('../../src/services/memo-reply') +const ReplyThreadPage = require('../../src/services/reply-thread-page') +const MemoSetName = require('../../src/services/memo-set-name') +const SetNamePage = require('../../src/services/set-name-page') +const AccountPage = require('../../src/services/account-page') +const MemoLike = require('../../src/services/memo-like') +const LikeTipPage = require('../../src/services/like-tip-page') + +const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX +const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX +const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX +const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX + +// Default author address used by Gherkin steps that refer to "the author address". +const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc' + +// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses. +function makeWallet (address) { + const wallet = { + walletInfo: { cashAddress: address }, + utxos: [], + broadcasts: [], + getUtxos: async function () { + return this.utxos + }, + sendOpReturn: async function (msg, prefix, bchOutput = []) { + // Record the broadcast attempt, then fail if configured to do so. + this.broadcasts.push({ msg, prefix, bchOutput }) + if (this.failWith) throw new Error(this.failWith) + return 'aa'.repeat(32) + } + } + return wallet +} + +// A fake feed reflecting posts added to the recent posts feed. +function makeFeed () { + const posts = [] + return { + posts, + addPost: (post) => posts.push(post) + } +} + +// A fake profile store recording display names set for addresses. +function makeProfiles () { + const names = {} + return { + names, + setName: (addr, name) => { names[addr] = name }, + getName: (addr) => names[addr] || null + } +} + +// A fake thread store recording replies added to a post thread. +function makeThread () { + const replies = [] + return { + rootTxid: null, + replies, + addReply: (r) => replies.push(r) + } +} + +// Fresh world/state object for a single scenario execution. +function createWorld () { + const wallet = makeWallet('') + const feed = makeFeed() + const memoPost = new MemoPost({ wallet, feed }) + const thread = makeThread() + const memoReply = new MemoReply({ wallet, thread }) + const memoLike = new MemoLike({ wallet, feed }) + + const world = { + wallet, + feed, + thread, + memoPost, + memoReply, + memoLike, + currentPath: null, + menuOpen: false, + likedTxids: new Set() + } + + // The New Post Page controller wraps the memo post behavior. Its navigate + // adapter updates the world's current path so navigation can be asserted. + world.newPage = new NewPostPage({ + memoPost, + navigate: (path) => { world.currentPath = path }, + menuLinks: [] + }) + + // The Reply Thread Page controller wraps the memo reply behavior. It does + // not navigate on success so the user stays in the thread modal. + world.replyPage = new ReplyThreadPage({ + memoReply, + navigate: () => {} + }) + + // The Like / Tip Page controller wraps the memo like behavior. + world.likeTipPage = new LikeTipPage({ memoLike }) + + // The Set Name Page and Account Page controllers share a profile store so + // a name set on one page is visible on the other. + const profiles = makeProfiles() + const memoSetName = new MemoSetName({ wallet, profiles }) + world.setNamePage = new SetNamePage({ + memoSetName, + navigate: (path) => { world.currentPath = path } + }) + world.accountPage = new AccountPage({ + wallet, + profiles, + navigate: (path) => { world.currentPath = path } + }) + + return world +} + +// Decode a raw reply payload into its parent txid (hex) and reply text. +function decodeReplyPayload (raw) { + const buf = Buffer.from(raw) + const parentTxid = buf.slice(0, 32).toString('hex') + const text = buf.slice(32).toString('utf8') + return { parentTxid, text } +} + +// Decode a raw like payload back into the liked post txid (hex). +function decodeLikeTxid (raw) { + return Buffer.from(raw).toString('hex') +} + +// Resolve a literal value or a placeholder from the example store. +function resolveParam (value, example) { + const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim()) + if (match) { + const param = match[1] + if (!(param in example)) { + throw new Error(`Missing example value for "${param}"`) + } + return example[param] + } + return String(value).trim() +} + +// Handler registry. Each entry: { pattern, run }. +// run receives (match, exampleStore, world, step). +const handlers = [ + { + name: 'wallet authenticated for address', + pattern: /^a wallet authenticated for the address (.+)$/, + run (m, example, world) { + world.wallet.walletInfo.cashAddress = m[1].trim() + } + }, + { + name: 'wallet has spendable output', + pattern: /^the wallet has (?:a )?spendable output to pay the transaction fee$/, + run (m, example, world) { + world.wallet.utxos = [{ txid: 'utxo-for-fee', value: 100000 }] + } + }, + { + name: 'viewing recent posts feed', + pattern: /^I am viewing the recent posts feed$/, + run (m, example, world) { + world.currentPath = NewPostPage.RECENT_FEED_PATH + } + }, + { + name: 'wallet fails to broadcast with error', + pattern: /^the wallet fails to broadcast with the error "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + if (!(param in example)) { + throw new Error(`Missing example value for "${param}"`) + } + world.wallet.failWith = example[param] + } + }, + { + name: 'navigate to path', + pattern: /^I navigate to the path (.+)$/, + run (m, example, world, step) { + const target = m[1].trim() + if (step.keyword === 'Then') { + if (world.currentPath !== target) { + throw new Error(`Expected to be on path ${target}, but current path is ${world.currentPath}.`) + } + } else { + world.currentPath = target + } + } + }, + { + name: 'remain on path', + pattern: /^I remain on the path (.+)$/, + run (m, example, world) { + const target = m[1].trim() + if (world.currentPath !== target) { + throw new Error(`Expected to remain on path ${target}, but current path is ${world.currentPath}.`) + } + } + }, + { + name: 'open navigation menu', + pattern: /^I open the navigation menu$/, + run (m, example, world) { + world.menuOpen = true + } + }, + { + name: 'menu shows link to path', + pattern: /^the menu shows a link to the path (.+)$/, + run (m, example, world) { + const target = m[1].trim() + if (!world.newPage.hasMenuLink(target)) { + throw new Error(`Navigation menu does not link to ${target}.`) + } + } + }, + { + name: 'compose/type memo text', + pattern: /^I (?:compose|type) a memo with the text "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + if (!(param in example)) { + throw new Error(`Missing example value for "${param}"`) + } + world.newPage.setInput(example[param]) + } + }, + { + name: 'type name text', + pattern: /^I type a name with the text "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + if (!(param in example)) { + throw new Error(`Missing example value for "${param}"`) + } + world.setNamePage.setInput(example[param]) + } + }, + { + name: 'submit/click post', + pattern: /^I (?:submit the memo|click the post button)$/, + async run (m, example, world) { + await world.newPage.submit() + } + }, + { + name: 'submit name', + pattern: /^I submit the name$/, + async run (m, example, world) { + await world.setNamePage.submit() + } + }, + { + name: 'thread modal shows reply form', + pattern: /^the thread modal shows a reply form$/, + run (m, example, world) { + // The reply form is always considered visible once the thread is open. + if (!world.replyPage) { + throw new Error('No reply page is attached to the thread.') + } + } + }, + { + name: 'post with txid has no replies', + pattern: /^a post with the txid (.+) has no replies$/, + run (m, example, world) { + const txid = m[1].trim() + world.thread.rootTxid = txid + world.replyPage.setParent(txid) + // A fresh thread store already has no replies. + if (world.thread.replies.length !== 0) { + throw new Error(`Expected post ${txid} to have no replies, but it has ${world.thread.replies.length}.`) + } + } + }, + { + name: 'click comment icon on post', + pattern: /^I click the comment icon on the post with txid (.+)$/, + run (m, example, world) { + const txid = m[1].trim() + // Opening the thread modal means setting the active thread txid. + world.thread.rootTxid = txid + world.replyPage.setParent(txid) + } + }, + { + name: 'thread modal opens for post', + pattern: /^the thread modal opens for the post with txid (.+)$/, + run (m, example, world) { + const txid = m[1].trim() + if (world.thread.rootTxid !== txid) { + throw new Error(`Expected thread modal to open for ${txid}, but current thread is ${world.thread.rootTxid}.`) + } + if (!world.replyPage) { + throw new Error('Thread modal opened without a reply form page.') + } + } + }, + { + name: 'open reply thread', + pattern: /^I open the thread for the post with txid (.+)$/, + run (m, example, world) { + const txid = m[1].trim() + world.thread.rootTxid = txid + world.replyPage.setParent(txid) + } + }, + { + name: 'type reply text', + pattern: /^I type a reply with the text "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + if (!(param in example)) { + throw new Error(`Missing example value for "${param}"`) + } + world.replyPage.setInput(example[param]) + world.replyPage.setParent(world.thread.rootTxid) + } + }, + { + name: 'type reply to nested reply', + pattern: /^I type a reply to the nested reply with the text "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + if (!(param in example)) { + throw new Error(`Missing example value for "${param}"`) + } + world.replyPage.setInput(example[param]) + if (!world.nestedTxid) { + throw new Error('No nested reply has been selected.') + } + world.replyPage.setParent(world.nestedTxid) + } + }, + { + name: 'submit reply', + pattern: /^I submit the reply$/, + async run (m, example, world) { + await world.replyPage.submit() + } + }, + { + name: 'thread shows nested reply', + pattern: /^the thread shows a nested reply with the txid (.+)$/, + run (m, example, world) { + const txid = m[1].trim() + world.nestedTxid = txid + world.thread.addReply({ + txid, + address: 'someone-else', + text: 'nested reply', + parentTxid: world.thread.rootTxid + }) + } + }, + { + name: 'click Set Name button', + pattern: /^I click the Set Name button$/, + run (m, example, world) { + world.accountPage.clickSetName() + } + }, + { + name: 'broadcasts/attempts OP_RETURN with Memo post prefix', + pattern: /^(?:the wallet|the app) (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo post prefix$/, + run (m, example, world) { + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No OP_RETURN transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (last.prefix !== MEMO_POST_PREFIX) { + throw new Error(`Expected Memo post prefix ${MEMO_POST_PREFIX}, got "${last.prefix}".`) + } + if (last.msg !== world.newPage.input) { + throw new Error('Broadcast message text did not match the composed memo.') + } + } + }, + { + name: 'broadcasts OP_RETURN with Memo set-name prefix', + pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-name prefix$/, + run (m, example, world) { + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No OP_RETURN transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (last.prefix !== MEMO_SET_NAME_PREFIX) { + throw new Error(`Expected Memo set-name prefix ${MEMO_SET_NAME_PREFIX}, got "${last.prefix}".`) + } + if (last.msg !== world.setNamePage.input) { + throw new Error('Broadcast name text did not match the typed name.') + } + } + }, + { + name: 'broadcasts OP_RETURN with Memo reply prefix', + pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo reply prefix$/, + run (m, example, world) { + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No OP_RETURN transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (last.prefix !== MEMO_REPLY_PREFIX) { + throw new Error(`Expected Memo reply prefix ${MEMO_REPLY_PREFIX}, got "${last.prefix}".`) + } + const { parentTxid, text } = decodeReplyPayload(last.msg) + if (parentTxid !== world.replyPage.parentTxid) { + throw new Error('Broadcast parent txid did not match the expected reply target.') + } + if (text !== world.replyPage.input) { + throw new Error('Broadcast reply text did not match the typed reply.') + } + } + }, + { + name: 'thread shows new reply from my address', + pattern: /^the thread shows a new reply from my address with the text "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + const expectedText = example[param] + const myAddress = world.wallet.walletInfo.cashAddress + const found = world.thread.replies.find( + (r) => r.text === expectedText && r.address === myAddress + ) + if (!found) { + throw new Error(`Thread does not show the new reply with text "${expectedText}".`) + } + } + }, + { + name: 'thread shows validation/length error', + pattern: /^the thread shows a (validation|length) error$/, + run (m, example, world) { + const kind = m[1] + const expectedCode = kind === 'validation' ? 'reply_validation' : 'reply_length' + if (world.replyPage.submitError !== expectedCode) { + throw new Error(`Expected ${expectedCode}, got ${world.replyPage.submitError}.`) + } + } + }, + { + name: 'thread remaining byte count', + pattern: /^the thread shows a remaining byte count of <([A-Za-z0-9_]+)>$/, + run (m, example, world) { + const param = m[1] + const expected = parseInt(example[param], 10) + if (Number.isNaN(expected)) { + throw new Error(`Invalid expected count for "${param}".`) + } + const actual = world.replyPage.remainingCount() + if (actual !== expected) { + throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`) + } + } + }, + { + name: 'feed shows new post from my address', + pattern: /^the feed shows a new post from my address with the text "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + const expectedText = example[param] + const myAddress = world.wallet.walletInfo.cashAddress + const found = world.feed.posts.find( + (p) => p.text === expectedText && p.address === myAddress + ) + if (!found) { + throw new Error(`Feed does not show the new post with text "${expectedText}".`) + } + } + }, + { + name: 'page shows error containing text', + pattern: /^the new post page shows an error containing "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + const expected = example[param] + const actual = world.newPage.broadcastError || '' + if (!actual.includes(expected)) { + throw new Error(`Expected an error containing "${expected}", got "${actual}".`) + } + } + }, + { + name: 'page shows validation/length error', + pattern: /^the (?:app|new post page) shows a (validation|length) error$/, + run (m, example, world) { + const kind = m[1] + const expectedCode = kind === 'validation' ? 'memo_validation' : 'memo_length' + if (world.newPage.submitError !== expectedCode) { + throw new Error(`Expected ${expectedCode}, got ${world.newPage.submitError}.`) + } + } + }, + { + name: 'set name page shows validation/length error', + pattern: /^the set name page shows a (validation|length) error$/, + run (m, example, world) { + const kind = m[1] + const expectedCode = kind === 'validation' ? 'name_validation' : 'name_length' + if (world.setNamePage.submitError !== expectedCode) { + throw new Error(`Expected ${expectedCode}, got ${world.setNamePage.submitError}.`) + } + } + }, + { + name: 'remaining character count', + pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/, + run (m, example, world) { + const param = m[1] + const expected = parseInt(example[param], 10) + if (Number.isNaN(expected)) { + throw new Error(`Invalid expected count for "${param}".`) + } + const actual = world.newPage.remainingCount() + if (actual !== expected) { + throw new Error(`Expected ${expected} remaining characters, got ${actual}.`) + } + } + }, + { + name: 'remaining byte count', + pattern: /^the set name page shows a remaining byte count of <([A-Za-z0-9_]+)>$/, + run (m, example, world) { + const param = m[1] + const expected = parseInt(example[param], 10) + if (Number.isNaN(expected)) { + throw new Error(`Invalid expected count for "${param}".`) + } + const actual = world.setNamePage.remainingCount() + if (actual !== expected) { + throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`) + } + } + }, + { + name: 'app does not broadcast any transaction', + pattern: /^(?:the wallet|the app) does not broadcast any transaction$/, + run (m, example, world) { + if (world.wallet.broadcasts.length !== 0) { + throw new Error('A transaction was broadcast when none was expected.') + } + } + }, + { + name: 'account page shows name', + pattern: /^the account page shows my name as "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + const expected = example[param] + const actual = world.accountPage.getName() + if (actual !== expected) { + throw new Error(`Expected account name "${expected}", got "${actual}".`) + } + } + }, + { + name: 'account page shows Set Name button', + pattern: /^the account page shows a Set Name button$/, + run (m, example, world) { + if (!world.accountPage.hasSetNameButton()) { + throw new Error('Account page does not show a Set Name button.') + } + } + }, + { + name: 'wallet has spendable balance', + pattern: /^the wallet has a spendable balance of (.+) sats$/, + run (m, example, world) { + const balance = parseInt(resolveParam(m[1], example), 10) + if (Number.isNaN(balance)) { + throw new Error(`Invalid balance value "${m[1]}"`) + } + world.wallet.utxos = [{ txid: 'utxo-for-balance', value: balance }] + } + }, + { + name: 'post with txid authored by author address', + pattern: /^a post with the txid (.+) authored by the author address$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const post = { + txid, + addr: AUTHOR_ADDRESS, + address: AUTHOR_ADDRESS, + text: 'A sample post', + likeCount: 0 + } + world.feed.addPost(post) + } + }, + { + name: 'post with txid authored by my address', + pattern: /^a post with the txid (.+) authored by my address$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const myAddress = world.wallet.walletInfo.cashAddress + const post = { + txid, + addr: myAddress, + address: myAddress, + text: 'My own post', + likeCount: 0 + } + world.feed.addPost(post) + } + }, + { + name: 'click heart icon on post', + pattern: /^I click the heart icon on the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const post = world.feed.posts.find((p) => p.txid === txid) + const authorAddress = post ? post.addr : AUTHOR_ADDRESS + world.likeTipPage.open(txid, authorAddress) + } + }, + { + name: 'like/tip modal opens for post', + pattern: /^a like\/tip modal opens for the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + if (!world.likeTipPage.modalOpen) { + throw new Error('Expected like/tip modal to be open.') + } + if (world.likeTipPage.postTxid !== txid) { + throw new Error(`Expected like/tip modal for ${txid}, but got ${world.likeTipPage.postTxid}.`) + } + } + }, + { + name: 'submit like without tip', + pattern: /^I submit the like without a tip$/, + async run (m, example, world) { + world.likeTipPage.setTip('') + const result = await world.likeTipPage.submit() + if (result.ok) { + world.likedTxids.add(world.likeTipPage.postTxid) + } + } + }, + { + name: 'enter tip', + pattern: /^I enter a tip of (.+)$/, + run (m, example, world) { + world.likeTipPage.setTip(resolveParam(m[1], example)) + } + }, + { + name: 'submit like', + pattern: /^I submit the like$/, + async run (m, example, world) { + const result = await world.likeTipPage.submit() + if (result.ok) { + world.likedTxids.add(world.likeTipPage.postTxid) + } + } + }, + { + name: 'broadcasts OP_RETURN with Memo like prefix', + pattern: /^the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No OP_RETURN transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (last.prefix !== MEMO_LIKE_PREFIX) { + throw new Error(`Expected Memo like prefix ${MEMO_LIKE_PREFIX}, got "${last.prefix}".`) + } + if (decodeLikeTxid(last.msg) !== txid) { + throw new Error(`Broadcast liked txid did not match ${txid}.`) + } + } + }, + { + name: 'wallet sends no tip', + pattern: /^the wallet sends no tip$/, + run (m, example, world) { + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (!Array.isArray(last.bchOutput) || last.bchOutput.length !== 0) { + throw new Error('Expected no tip output, but one was present.') + } + } + }, + { + name: 'wallet sends tip to author', + pattern: /^the wallet sends a tip of (.+) to the author address$/, + run (m, example, world) { + const expectedTip = parseInt(resolveParam(m[1], example), 10) + if (Number.isNaN(expectedTip)) { + throw new Error(`Invalid tip value "${m[1]}"`) + } + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (!Array.isArray(last.bchOutput) || last.bchOutput.length === 0) { + throw new Error('Expected a tip output, but none was present.') + } + const tipOutput = last.bchOutput[0] + if (tipOutput.amountSat !== expectedTip) { + throw new Error(`Expected tip ${expectedTip} sats, got ${tipOutput.amountSat}.`) + } + const post = world.feed.posts.find((p) => p.txid === world.likeTipPage.postTxid) + const expectedAddress = post ? post.addr : AUTHOR_ADDRESS + if (tipOutput.address !== expectedAddress) { + throw new Error(`Expected tip to ${expectedAddress}, got ${tipOutput.address}.`) + } + } + }, + { + name: 'like count increases by one', + pattern: /^the like count on the post increases by one$/, + run (m, example, world) { + const postTxid = world.likeTipPage.postTxid + const post = world.feed.posts.find((p) => p.txid === postTxid) + if (!post) { + throw new Error(`Post ${postTxid} not found in feed.`) + } + if (post.likeCount !== 1) { + throw new Error(`Expected like count to be 1, got ${post.likeCount}.`) + } + } + }, + { + name: 'heart icon shows as filled', + pattern: /^the heart icon on the post shows as filled$/, + run (m, example, world) { + const postTxid = world.likeTipPage.postTxid + if (!world.likedTxids.has(postTxid)) { + throw new Error(`Expected heart icon to be filled for ${postTxid}.`) + } + } + }, + { + name: 'like/tip modal shows error containing text', + pattern: /^the like\/tip modal shows an error containing "(.+)"$/, + run (m, example, world) { + const expected = m[1] + const actual = world.likeTipPage.broadcastError || '' + if (!actual.includes(expected)) { + throw new Error(`Expected an error containing "${expected}", got "${actual}".`) + } + } + }, + { + name: 'click cancel button', + pattern: /^I click the cancel button$/, + run (m, example, world) { + world.likeTipPage.close() + } + }, + { + name: 'like/tip modal closes', + pattern: /^the like\/tip modal closes$/, + run (m, example, world) { + if (world.likeTipPage.modalOpen) { + throw new Error('Expected like/tip modal to be closed.') + } + } + } +] + +// Route a single step to its handler. Throws on unsupported step text. +async function handleStep (step, example, world) { + for (const handler of handlers) { + const match = handler.pattern.exec(step.text) + if (match) { + await handler.run(match, example, world, step) + return + } + } + throw new Error(`Unsupported step: ${step.keyword} ${step.text}`) +} + +module.exports = { createWorld, handleStep } diff --git a/psf-memo-client/acceptance/lib/runner-worker.js b/psf-memo-client/acceptance/lib/runner-worker.js new file mode 100644 index 0000000..332eaae --- /dev/null +++ b/psf-memo-client/acceptance/lib/runner-worker.js @@ -0,0 +1,68 @@ +/* + Persistent runner adapter for the APS gherkin-mutator. + + The mutator starts this process (once per worker) and sends mutation jobs + over newline-delimited JSON on stdin. Each job carries the path to a mutated + feature JSON IR; this worker evaluates it through the same acceptance runtime + and step handlers used by the normal acceptance pipeline and replies with the + runner outcome. + + Protocol (mutator-spec.md): + request: { "id", "feature_json", "generated_dir", "work_dir" } + response: { "id", "outcome", "output", "error", "duration" } + outcome: test_success | test_failure | infrastructure_error + + test_failure (acceptance failed) -> mutation killed + test_success (acceptance passed) -> mutation survived +*/ + +'use strict' + +const readline = require('node:readline') +const fs = require('node:fs') +const { runFeature } = require('./runtime') + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false +}) + +rl.on('line', async (line) => { + const started = Date.now() + const respond = (payload) => { + process.stdout.write(`${JSON.stringify(payload)}\n`) + } + + let job + try { + job = JSON.parse(line) + } catch (err) { + respond({ id: 'unknown', outcome: 'infrastructure_error', output: '', error: `bad job: ${err.message}`, duration: Date.now() - started }) + return + } + + try { + const ir = JSON.parse(fs.readFileSync(job.feature_json, 'utf8')) + const report = await runFeature(ir) + respond({ + id: job.id, + outcome: report.failures === 0 ? 'test_success' : 'test_failure', + output: report.results.map((r) => `${r.status} ${r.name}`).join('\n'), + error: '', + duration: Date.now() - started + }) + } catch (err) { + respond({ + id: job.id, + outcome: 'infrastructure_error', + output: '', + error: err.message, + duration: Date.now() - started + }) + } +}) + +rl.on('close', () => { + process.exit(0) +}) diff --git a/psf-memo-client/acceptance/lib/runtime.js b/psf-memo-client/acceptance/lib/runtime.js new file mode 100644 index 0000000..71c3421 --- /dev/null +++ b/psf-memo-client/acceptance/lib/runtime.js @@ -0,0 +1,69 @@ +/* + Acceptance runtime for psf-memo-client. + + Expands each scenario (and each example row) from parser JSON IR into + scenario executions, prepends background steps, and dispatches every step to + the project step handlers. Unsupported steps, invalid example values, or + failed assertions fail that execution. +*/ + +'use strict' + +const { createWorld, handleStep } = require('./handlers') + +// Expand the IR scenarios into concrete executions. +// For scenario outlines with examples, one execution per example row; for +// scenarios without examples, one execution with an empty example store. +function expandScenarios (ir) { + const executions = [] + const background = ir.background || [] + + for (const scenario of ir.scenarios) { + const examples = (scenario.examples && scenario.examples.length > 0) + ? scenario.examples.map((example, i) => ({ example, suffix: `example_${i + 1}` })) + : [{ example: {}, suffix: 'example_1' }] + + for (const { example, suffix } of examples) { + executions.push({ + name: `${scenario.name}/${suffix}`, + steps: [...background, ...scenario.steps], + example + }) + } + } + + return executions +} + +// Run a full feature and return a report of scenario outcomes. +async function runFeature (ir) { + const executions = expandScenarios(ir) + const results = [] + let failures = 0 + + for (const ex of executions) { + // A fresh world/state object for each scenario execution. + const world = createWorld() + let failure = null + + for (const step of ex.steps) { + try { + await handleStep(step, ex.example, world) + } catch (err) { + failure = err.message + break + } + } + + if (failure) { + failures++ + results.push({ name: ex.name, status: 'failed', detail: failure }) + } else { + results.push({ name: ex.name, status: 'passed' }) + } + } + + return { feature: ir.name, results, failures, total: results.length } +} + +module.exports = { expandScenarios, runFeature } diff --git a/psf-memo-client/deploy/README.md b/psf-memo-client/deploy/README.md new file mode 100644 index 0000000..f51556f --- /dev/null +++ b/psf-memo-client/deploy/README.md @@ -0,0 +1,17 @@ +# Deploy +This directory contains scripts for deploying the app to different platforms and blockchains. + +## App Deployment + +### Blockchains +- Filecoin - The compiled app is uploaded to the Filecoin blockchain using [publish-filecoin.js](./publish-filecoin.js). Running this script requires a free API key from [web3.storage](https://web3.storage). +- IPFS - The files are also pinned by the Pinata service using [publish-pinata.js](./publish-filecoin.js). Running this script requires a free JWT token from [Pinata](https://pinata.cloud). +- Bitcoin Cash - The IPFS CID is written to the Bitcoin Cash blockchain with [publish-bch.js](/publish-bch.js) This creates an immutable, censorship-resistant, globally available, and secure pointer to the latest version of the app. + +The above deployment scripts are orchestrated with [publish-main.js](`./publish-main.js`). This script is run by executing `npm run pub`. + +### GitHub Pages +The app can also be deployed to GitHub pages. This requires switching to the `gh-pages` branch and running the command `npm run pub:ghp`. + +## Code Deployment +The code in this repository is backed up to the [Radicle](https://radicle.network/get-started.html) network, as GitHub has been increasing its censorship of code. Find instructions for *consuming* the code in the [top-level README](../README.md). To learn how install Radicle on your own machine and collaborate on the code that way, check out [this research article](https://christroutner.github.io/trouts-blog/docs/censorship/radicle). diff --git a/psf-memo-client/deploy/publish-bch.js b/psf-memo-client/deploy/publish-bch.js new file mode 100644 index 0000000..27a285c --- /dev/null +++ b/psf-memo-client/deploy/publish-bch.js @@ -0,0 +1,52 @@ +/* + This script will write the CID for the current version of the app to an + address on the BCH blockchain. This creates an immutable, censorship-resistant, + globally available, and secure pointer to the latest version of the app. + + The exported function expects an IPFS CID as input and returns a TXID for a + BCH transaction. + + This function expects this environtment variable to contain a WIF private key + with BCH to write to the blockchain: + - REACT_BOOTSTRAP_WEB3_SPA_WIF +*/ + +// Global npm libraries +// const BCHJS = require('@psf/bch-js') +const BchWallet = require('minimal-slp-wallet/index') +const BchMessageLib = require('bch-message-lib/index') + +async function publishToBch (cid) { + try { + // Get the Filecoin token from the environment variable. + const wif = process.env.REACT_BOOTSTRAP_WEB3_SPA_WIF + if (!wif) { + throw new Error( + 'WIF private key not detected. Get a private key from https://wallet.fullstack.cash and save it to the REACT_BOOTSTRAP_WEB3_SPA_WIF environment variable.' + ) + } + + // Initialize libraries for working with BCH blockchain. + // const bchjs = new BCHJS() + const wallet = new BchWallet(wif, { + interface: 'consumer-api' + }) + await wallet.walletInfoPromise + await wallet.initialize() + const bchMsg = new BchMessageLib({ wallet }) + + // Publish the CID to the BCH blockchain. + const hex = await bchMsg.memo.memoPush(cid, 'IPFS UPDATE') + + // Broadcast the transaction to the network. + const txid = await wallet.ar.sendTx(hex) + // console.log(`BCH blockchain updated with new CID. TXID: ${txid}`) + // console.log(`https://blockchair.com/bitcoin-cash/transaction/${txid}`) + + return txid + } catch (err) { + console.error(err) + } +} + +module.exports = publishToBch diff --git a/psf-memo-client/deploy/publish-filecoin.js b/psf-memo-client/deploy/publish-filecoin.js new file mode 100644 index 0000000..b5410fa --- /dev/null +++ b/psf-memo-client/deploy/publish-filecoin.js @@ -0,0 +1,82 @@ +/* + This library is used to publish the compiled app to Filecoin. + The publishToFilecoin() function will upload the 'build' folder to Filecoin + via the web3.storage API. + The function will return an object that contains the CID of the uploaded + directory, and a URL for loading the app in a browser. + + In order to run this script, you must obtain an API key from web3.storage. + That key should be saved to an environment variable named FILECOIN_TOKEN. +*/ + +const { Web3Storage, getFilesFromPath } = require('web3.storage') +const fs = require('fs') + +async function publish () { + try { + const currentDir = `${__dirname}` + // console.log(`Current directory: ${dir}`) + const buildDir = `${currentDir}/../build` + + // Get the Filecoin token from the environment variable. + const filecoinToken = process.env.FILECOIN_TOKEN + if (!filecoinToken) { + throw new Error( + 'Filecoin token not detected. Get a token from https://web3.storage and save it to the FILECOIN_TOKEN environment variable.' + ) + } + + // Get a list of all the files to be uploaded. + const fileAry = await getFileList(buildDir) + // console.log(`fileAry: ${JSON.stringify(fileAry, null, 2)}`) + + // Upload the files to Filecoin. + const cid = await uploadToFilecoin(fileAry, filecoinToken) + + // console.log('Content added to Filecoin with CID:', cid) + // console.log(`https://${cid}.ipfs.dweb.link/`) + + return cid + } catch (err) { + console.error(err) + } +} + +function getFileList (buildDir) { + const fileAry = [] + + return new Promise((resolve, reject) => { + fs.readdir(buildDir, (err, files) => { + if (err) return reject(err) + + files.forEach(file => { + // console.log(file) + fileAry.push(`${buildDir}/${file}`) + }) + + return resolve(fileAry) + }) + }) +} + +async function uploadToFilecoin (fileAry, token) { + const storage = new Web3Storage({ token }) + + const files = [] + for (let i = 0; i < fileAry.length; i++) { + const thisPath = fileAry[i] + // console.log('thisPath: ', thisPath) + + const pathFiles = await getFilesFromPath(thisPath) + // console.log('pathFiles: ', pathFiles) + + files.push(...pathFiles) + } + + console.log(`Uploading ${files.length} files. Please wait...`) + const cid = await storage.put(files) + + return cid +} + +module.exports = publish diff --git a/psf-memo-client/deploy/publish-gh-pages.sh b/psf-memo-client/deploy/publish-gh-pages.sh new file mode 100755 index 0000000..41f1877 --- /dev/null +++ b/psf-memo-client/deploy/publish-gh-pages.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Bash shell script to publish the app to GitHub pages. +# Ensure you are in the gh-pages branch. + +#pwd +#git checkout gh-pages +#git merge master + +npm run build +cp -r build docs +git add -A +git commit -m "Updating GitHub page" +git push diff --git a/psf-memo-client/deploy/publish-main.js b/psf-memo-client/deploy/publish-main.js new file mode 100644 index 0000000..c1dc14f --- /dev/null +++ b/psf-memo-client/deploy/publish-main.js @@ -0,0 +1,29 @@ +/* + This is the main publish file that aggregates the other publish libraries + and orchestrates them, so that one command can publish to different platforms. +*/ + +// Local libraries +const publishToFilecoin = require('./publish-filecoin') +const publishToPinata = require('./publish-pinata') +const publishToBch = require('./publish-bch') + +async function publish () { + try { + // Publish to Filecoin + const cid = await publishToFilecoin() + console.log('Content added to Filecoin with CID:', cid) + console.log(`https://${cid}.ipfs.dweb.link/`) + + // Publish to Pinata + await publishToPinata(cid) + + // Public to BCH + const txid = await publishToBch(cid) + console.log(`\nBCH blockchain updated with new CID. TXID: ${txid}`) + console.log(`https://blockchair.com/bitcoin-cash/transaction/${txid}`) + } catch (err) { + console.error('Error while trying to publish app: ', err) + } +} +publish() diff --git a/psf-memo-client/deploy/publish-pinata.js b/psf-memo-client/deploy/publish-pinata.js new file mode 100644 index 0000000..30e1b17 --- /dev/null +++ b/psf-memo-client/deploy/publish-pinata.js @@ -0,0 +1,52 @@ +/* + This library will pin the app to Pinata. It expects a CID + as input, which is the output of publish-filecoin.js. + + Filecoin should be though of as cold-storage for data. It's very slow to + retrieve. Pinata can be though of as RAM. It keeps content at the ready and + fast to deliver. They are complimentary services. + + In order to run this script, you must obtain an API key from pinata.cloud. + That key should be saved to an environment variable named PINATA_JWT. +*/ + +const axios = require('axios') + +async function publishToPinata (cid) { + // Get the Pinata token from the environment variable. + const pinataToken = process.env.PINATA_JWT + if (!pinataToken) { + throw new Error( + 'Pinata JWT token not detected. Get a token from https://pinata.cloud and save it to the PINATA_JWT environment variable.' + ) + } + + const now = new Date() + + const data = JSON.stringify({ + hashToPin: cid, + pinataMetadata: { + name: 'react-bootstrap-web3-spa', + keyvalues: { + timestamp: now.toISOString() + } + } + }) + + const config = { + method: 'post', + url: 'https://api.pinata.cloud/pinning/pinByHash', + headers: { + Authorization: `Bearer ${pinataToken}`, + 'Content-Type': 'application/json' + }, + data + } + + const res = await axios(config) + + console.log('\nCID pinned using Pinata:') + console.log(res.data) +} + +module.exports = publishToPinata diff --git a/psf-memo-client/dev-docs/README.md b/psf-memo-client/dev-docs/README.md new file mode 100644 index 0000000..020baf7 --- /dev/null +++ b/psf-memo-client/dev-docs/README.md @@ -0,0 +1,28 @@ +# Developer Docs + +This file contains notes taken during software development. These notes may eventually be edited into informaiton that goes into the top-level README, or other documentation. + +## Main Features of this App + +- [react-bootstrap](https://react-bootstrap.github.io/) is used for general style and layout control. +- An easily customizable waiting modal component can be invoked while waiting for network calls to complete. +- [minimal-slp-wallet](https://www.npmjs.com/package/minimal-slp-wallet) is used to access tokens and BCH on the Bitcoin Cash blockchain. +- A 'server selection' dropdown allows the user to select from an array of redundent back end servers. +- This site is statically compiled, uploaded to Filecoin, and served over IPFS for censorship resistance and version control. + +## File Layout + +The top-level file layout of this app looks like this: + +- App.js - the main application orchestates these child components: + - GetRestUrl - retrieves the REST URL for the selected back-end web3 server from query paramenters in the URL. + - LoadScripts - Loads the modal with a waiting spinner animation until the external script files are loaded. + - NavMenu - the collapsible navigation menu + - InitializedView & UnitializedView - the default Views that are displayed depending on the state of the app. + - ServerSelect - allows the user to select a different web3 back end server. + - Footer - Footer links + +After initialization, the InitailizedView is displayed. This loads the AppBody, which is a wrapper for each View. Views are selected using the navigation menu. When one View is selected, the others are hidden. + +## Loading of Wallet +The wallet library [minimal-slp-wallet](https://www.npmjs.com/package/minimal-slp-wallet) is loaded at startup, and initialized with a web3 back end server. By default, the back-end server is free-bch.fullstack.cash. However, a list of back end servers provided by the [PSF](https://psfoundation.cash) are loaded into a drop-down from a GitHub diff --git a/psf-memo-client/dev-docs/psf-memo-db-changes.md b/psf-memo-client/dev-docs/psf-memo-db-changes.md new file mode 100644 index 0000000..136525c --- /dev/null +++ b/psf-memo-client/dev-docs/psf-memo-db-changes.md @@ -0,0 +1,63 @@ +# psf-memo-db changes to support Like counts + +**Status**: DRAFT — notes for a future session. The current Like/Tip feature +(`0x6d04`) focuses on the **UI to broadcast a like** (and an optional tip). The +read-side changes below are **not** implemented now; they are recorded here so the +like-count read path can be developed later. + +Owner: specifier. +Last updated: 2026-08-26. + +--- + +## Goal + +Expose like counts (and, later, liked-state and a likers list) so the psf-memo-client +UI can show a real like count on each post and whether the viewing user already liked +it. Today `/posts/*` responses omit likes entirely. + +## What the indexer already provides + +The Memo **indexer** (`psf-memo-indexer`) already parses `0x6d04` like/tip actions into +the DB as social references: a **liker address** → a **liked post txid** (with an +optional tip value). This feature does not require indexer changes to record likes; it +requires the **DB query/API** layer to aggregate and expose them. + +## Required psf-memo-db changes + +1. **`likeCount` on post responses.** + Add a `likeCount` field (number of distinct `0x6d04` references whose liked txid + equals the post txid) to the objects returned by: + - `/posts/recent` (feed items) + - `/post/:txid` (thread root) + - thread reply nodes (when replies are also likeable / shown with counts) + Aggregate the count in the query rather than an N+1 per-post lookup. + +2. **Liked-state for the viewing user (optional, later).** + To render a filled heart when the current wallet has already liked a post, the + read endpoints need to know the viewer. Add an optional `viewer=
` query + param (or equivalent) to the relevant post endpoints and return + `liked: true|false` per post based on whether `viewer` has a `0x6d04` reference to + that txid. Until this exists, the client can track "liked" locally/optimistically + for the current session only. + +3. **Likers list endpoint (later).** + memo.cash shows a modal listing who liked a post (its `post/likes`). Add an endpoint + e.g. `GET /post/:txid/likes` returning `[{ address, name, profilePicUrl, tip }...]` + for the addresses that liked the post, ordered by time/tip, joined with the profile + store to avoid N+1 lookups. Used by a future "likes" modal. + +4. **Join efficiency.** + Like counts must be aggregated server-side (e.g. a counter derived from the + reference index or a materialized count) and included in the same response as the + post text, author, name, and avatar — avoid N+1 per-item like lookups in feed and + thread responses. + +## Out of scope (this session) + +- The like-count **read surface** (count badge, liked-state, likers modal). +- `viewer` liked-state param. +- Likers list endpoint. + +All of the above are future work; the UI spec for this session only broadcasts the +like/tip and increments a count **optimistically**. diff --git a/psf-memo-client/img/donation-qr.png b/psf-memo-client/img/donation-qr.png new file mode 100644 index 0000000000000000000000000000000000000000..a0efbbfa12fcfcbbdb8e67be2177f2d244c365a2 GIT binary patch literal 2028 zcmb7Fdr(tX9*%Gg7YF3R6g9YPFWp3U0k!fFotVOfAjTk|gjW$kOiYVeOA(0j_KL_R zY9r7f6vBYKQdU&xDg=Zwfi`WW86Zkii8L(lrJ)EI9tHPYm_BD`XZpvz=Qrp4&hPPk z-#O>c2o9s4`NF+iY-G~8~dE@WWnrgPnaptKCqcLjk$Q_|Nc>_x&!&>tGIm=g8a>Dl~=W?Yn-*j=^! zeON0EM^!wTVrvbfsWqS48H2*To`r^oen`HrDhoZC8h>#oE4RZ7sm$E1d$0XN)b`rb zZi{~{VTR}3@|@*A`}5e|kIgQl?(PG|7{^GP*tfPpa=E_EELfX|%yyuMk6IcTx4x8S z-?AcOadA!49h?VLW~-p6)n6S)(HLvTY04XGNmMzPF$AJ0@~K>92MXgBI+jg?J_doX z90S!{8~-MZqdvks5a_d{1<|TwiXp}7s)siM-5hD2?$GFH4x^Xfw{NRx7s;%t+d&=u zdZ(qjaSGP(n}rvW4$i>sv!O+H0kdCeN;hDl=}YKpYP?FCeUMA!$ZViFSWzqhEM)?g z%7=UzgZ4btngsNhQ=g`ubL4)`dv>31 z_WXRX1AX_i8w~mnvX3@t-`6HjX)pbKRWx$aqc1w_x`nM#l~!Yd`4?^l_+bcFD|AFU z6E9i=8^WaNHL($jWm27){gXQjG<{;S+ufll-UH_e`ZsAaz$d4H#~SPy6@P%jE(N>T zOPlvar$LycToBPKytg>?7eEA{xKsJ1CjMn9!g4f zv%(OnB`$ExEf#&w5my>$K3zJOq$iBK1!SU<3i=m$*??E{FbM|(P%eJ(h@`%X)8DO8 zedku_ueRma5sm;LFaUslHp8Dr6uQ~EyIhQV(ZbF@#ft(fa>ZmL1-KxU^4$P@PSm{C z0tAP9IYMpp{p1tlBFfZXkDehHHQF+wU((D$$mX81V0B?S3up#AH#er4a~*D)Ga|By5LaMmiEdwGlKJx+kiY zN3MDmm$xV%e{W7uU9b4;9a+lPQSGF4#URX((}2rSfdBob?L^+z5Q#o^$f5T2Z}gbq zrANxX+0tTBH0%f}a5jt<2vt(r_1AomiNPz!8$<+`jp zt!6X{Z}QAl6kad z@{Y<_t!71D=y5)ju@xMTG!Lj~p}xWsnz``*t%tnKuF0h@=4#%G-0M`c{PEdZzNaY= zb8lmmYY>rZPS9_`A(&@8b2qgGB4DeNyS=*hy^5=-uggfBk4RG6ip!d>24`LsuwY!J z{x~)HZ7<{MW^e;RH_JkdQ=eoRR_wuz2EXB1XVAX8LAOljvb_;zMyRf_&qQGawI+Lz8^KhU{GG{Ue9?QQC|4t zz;q9yye+fAgGV>wXl~cKOGSC;!82FlDT)jhacMBgfL-8UqDh)w+yo{laKaX#u!Q6N zkH(yiY&;h1&^s=S;y-aeBwyf-p_X`Vn-2mVSQESCw};L~dHmJ2mt%9KOM>d0lw(dW zLUEKlC|93}Lsel6R_>*3H^kFzFzgZ(XcLR({ClWnj|py}$ + {/* should be wrap all the components that use react-router-dom */} + + + + +) + +// Updating to React v18 +// https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#updates-to-client-rendering-apis + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/util/index.js`: + +```js +/* + A utility library for holding functions that are commonly used by many different + areas of the app. +*/ + +class AppUtil { + // Returns a promise that resolves 'ms' milliseconds. + sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + // Copy a text to clipboard + async copyToClipboard (text) { + try { + await navigator.clipboard.writeText(text) + } catch (err) { + console.error('Failed to copy text:', err) + // document.body.removeChild(textarea) + return false + } + } + + // Read text from clipboard + async readFromClipboard () { + try { + const text = await navigator.clipboard.readText() + + return text + } catch (err) { + console.error('Failed to copy text:', err) + // document.body.removeChild(textarea) + return false + } + } +} + +export default AppUtil + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/footer/index.js`: + +```js +/* + A footer section for the SPA +*/ + +// Global npm libraries +import React, { useEffect } from 'react' +import { Container, Row, Col } from 'react-bootstrap' + +// Local libraries +import config from '../../config' +// import Memo from './get-cid' + +function Footer (props) { + // const [ipfsCid, setIpfsCid] = useState(config.ipfsCid) + const wallet = props.appData.wallet + + // Retrieve the most up-to-date CID for the app on Filecoin from the BCH blockchain. + useEffect(() => { + async function fetchData () { + try { + // const hash = await getUpdatedUrl(wallet) + // if (hash) { + // setIpfsCid(hash) + // } + } catch (err) { + console.error('Error trying to retrieve Filecoin CID for the app from the BCH blockchain.') + } + } + fetchData() + }, [wallet]) + + return ( + + + +
Source Code
+ + + + +
+
+ ) +} + +// async function getUpdatedUrl (wallet) { +// try { +// // Exit if the wallet is not initialized. +// if (!wallet) return + +// // Initialize the memo library for retrieving data from the BCH blockchain. +// const memo = new Memo({ bchAddr: config.appBchAddr }) +// await memo.initialize(wallet) +// const hash = await memo.findHash() + +// if (!hash) { +// console.error( +// `Could not find IPFS hash in transactions for address ${config.appBchAddr}` +// ) +// return false +// } +// // console.log(`latest IPFS hash: ${hash}`) + +// return hash +// } catch (err) { +// console.log('Error in getUpdatedUrl(): ', err) +// } +// } + +export default Footer + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/footer/get-cid.js`: + +```js +/* + This service file contains functions for retrieving an IPFS hash from the + BCH blockchain, in a fasion similar to PS001: + https://github.com/Permissionless-Software-Foundation/specifications/blob/master/ps001-media-sharing.md +*/ + +// Global npm libraries +import BchMessage from 'bch-message-lib' + +// Local libraries +import AppUtil from '../../util' + +class Memo { + constructor (config) { + this.config = config + + // Encapsulate dependencies + this.util = new AppUtil() + } + + // Instantiate the bch-message-lib library. + async initialize (wallet) { + try { + // Throw an error if this class is instantiated without passing a BCH address. + if (!this.config || !this.config.bchAddr) { + throw new Error('Must pass a BCH address to Memo constructor.') + } else { + this.bchAddr = this.config.bchAddr + } + + this.wallet = wallet + + this.bchMessage = new BchMessage({ wallet }) + } catch (err) { + console.error('Error in get-cid.js/initialize(): ', err.message) + // console.log('Waiting 5 seconds before trying again.') + // await this.util.sleep(5000) + // this.initialize() + } + } + + // Walk the transactions associated with an address until a proper IPFS hash is + // found. If one is not found, will return false. + async findHash () { + try { + console.log(`Finding latest IPFS hash for address: ${this.bchAddr}...`) + + const txs = await this.bchMessage.memo.memoRead( + this.bchAddr, + 'IPFS UPDATE' + ) + // console.log(`txs: ${JSON.stringify(txs, null, 2)}`) + + // If the array is empty, then return false. + if (txs.length === 0) return false + + const hash = txs[0].subject + console.log(`...found this IPFS hash: ${hash}`) + + // The transactions should automatically be sorted by the bchMessage + // library. So Just return the subject. + return hash + } catch (err) { + console.warn('Could not find IPFS hash in transaction history.') + return false + } + } +} + +export default Memo + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/nav-menu/index.js`: + +```js +/* + This component controlls the navigation menu. + + Inspired from this example: + https://codesandbox.io/s/react-bootstrap-hamburger-menu-example-rnud4?from-embed +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Nav, Navbar, Image } from 'react-bootstrap' // Used for Navbar Style and Layouts . +import { NavLink } from 'react-router-dom' // Used to navigate between routes + +// Assets +import Logo from './psf-logo.png' + +function NavMenu (props) { + // Get the current path + const { currentPath } = props.appData + + // Navbar state + const [expanded, setExpanded] = useState(false) + + // Handle click event + const handleClickEvent = () => { + // Collapse the navbar + setExpanded(false) + } + + return ( + <> + + + {' '} + SLP Wallet + + + + + + + + + ) +} + +export default NavMenu + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/balance.js`: + +```js +/* + Component for looking up the balance of a BCH address. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Container, Row, Col, Form, Button, Spinner } from 'react-bootstrap' + +function GetBalance (props) { + const { wallet } = props + + // State + const [balance, setBalance] = useState('') + const [textInput, setTextInput] = useState('') + + // Button click handler + const handleGetBalance = async (e) => { + e.preventDefault() + try { + // Exit on invalid input + if (!textInput) return + if (!textInput.includes('bitcoincash:')) return + + setBalance( +
+ Retrieving balance... + +
+ ) + + const balance = await wallet.getBalance({ bchAddress: textInput }) + console.log('balance: ', balance) + + const bchBalance = wallet.bchjs.BitcoinCash.toBitcoinCash(balance) + + setBalance(`Balance: ${balance} sats, ${bchBalance} BCH`) + } catch (err) { + setBalance(

Error: {`${err.message}`}

) + } + } + + return ( + <> + + + +
+ + Enter any BCH address to query its balance on the blockchain. + setTextInput(e.target.value)} /> + + + +
+ +
+
+ + + {balance} + + +
+ + ) +} + +export default GetBalance + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/index.js`: + +```js +/* + This Body component is a container for all the different Views of the app. + Views are equivalent to 'pages' in a multi-page app. Views are hidden or + displayed to simulate the use of pages in an SPA. + The Body app contains all the Views and chooses which to show, based on + the state of the Menu component. +*/ + +// Global npm libraries +import React from 'react' +import { Route, Routes } from 'react-router-dom' + +// Local libraries +import GetBalance from './balance' +import Wallet from './bch-wallet' +import Placeholder2 from './placeholder2' +import Placeholder3 from './placeholder3' +// import ServerSelectView from './servers/select-server-view' +// import SelectServerButton from './servers/select-server-button' +import BchSend from './bch-send' +import SlpTokens from './slp-tokens' +import SweepWif from './sweep/index.js' +import SignMessage from './sign/index.js' +import ServerSelectView from './configuration/select-server-view' + +function AppBody (props) { + // Dependency injection through props + const appData = props.appData + + return ( + <> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/** Show in all paths except the servers view */} + {/* {appData.currentPath !== '/servers' && } */} + + ) +} + +export default AppBody + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-send/index.js`: + +```js +/* + This View allows sending and receiving of BCH +*/ + +// Global npm libraries +import React from 'react' +import { Container, Row, Col } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons' + +// Local libraries +import RefreshBchBalanceButton from './refresh-bch-balance-button' +import SendCard from './send-card' +import BalanceCard from './balance-card' +import ReceiveCard from './receive-card' + +// Working array for storing modal output. +// this.modalBody = [] + +function BchSend ({ appData }) { + return ( + <> + + + + + + + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + ) +} + +export default BchSend + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-send/send-card.js`: + +```js +/* + This component controls sending of BCH. +*/ + +// Global npm libraries +import React, { useState, useRef } from 'react' +import { Container, Row, Col, Card, Form, Button } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPaperPlane, faPaste, faRandom } from '@fortawesome/free-solid-svg-icons' + +// Local libraries +import WaitingModal from '../../waiting-modal' +import RefreshBchBalance from './refresh-balance' + +function SendCard (props) { + // Dependency injection through props + const appData = props.appData + + // Modal State + const [modalBody, setModalBody] = useState([]) + const [hideSpinner, setHideSpinner] = useState(false) + const [hideWaitingModal, setHideWaitingModal] = useState(true) + const [hideModal, setHideModal] = useState(true) + + // Form State + const [bchAddr, setBchAddr] = useState('') + const [amountStr, setAmountStr] = useState('') + const [amountUnits, setAmountUnits] = useState('USD') + const [oppositeUnits, setOppositeUnits] = useState('BCH') + const [oppositeQty, setOppositeQty] = useState(0) + + // Child function references + const refreshBchBalanceRef = useRef() + + // Update the balance of the wallet. + async function handleButtonRefreshBalance (appData) { + // Call the child function + refreshBchBalanceRef.current.handleRefreshBalance(appData) + } + + // Encapsulate the state for this component into a single object that can + // be passed around to subfunctions and subcomponents. + const sendCardData = { + modalBody, + setModalBody, + hideSpinner, + setHideSpinner, + hideWaitingModal, + setHideWaitingModal, + hideModal, + setHideModal, + bchAddr, + setBchAddr, + amountStr, + setAmountStr, + amountUnits, + setAmountUnits, + oppositeUnits, + setOppositeUnits, + oppositeQty, + setOppositeQty + } + + // This function is called when the modal is closed. + function onModalClose () { + sendCardData.setHideModal(true) + + handleButtonRefreshBalance(appData) + } + + async function pasteFromClipboard () { + try { + const addr = await appData.appUtil.readFromClipboard() + sendCardData.setBchAddr(addr) + } catch (err) { + // Browser implementation. Exit quietly. + } + } + + // This is an on-change event handler that updates the amount calculated in + // both BCH and USD as the user types. + function handleUpdateAmount (inObj = {}) { + try { + const { event, appData, sendCardData } = inObj + + // Update the state of the text box. + let amountStr = event.target.value + sendCardData.setAmountStr(amountStr) + if (!amountStr) amountStr = '0' + + // Convert the string to a number. + const amountQty = parseFloat(amountStr) + + const bchUsdPrice = appData.bchWalletState.bchUsdPrice + const bchjs = appData.wallet.bchjs + + // Initialize local variables + let oppositeQty = 0 + // const amountUsd = 0 + // const amountBch = 0 + + // Calculate the amount in the opposite units. + const currentUnit = sendCardData.amountUnits + if (currentUnit.includes('USD')) { + // Convert USD to BCH + oppositeQty = bchjs.Util.floor8(amountQty / bchUsdPrice) + // amountUsd = amountQty + // amountBch = oppositeQty + } else { + // Convert BCH to USD + oppositeQty = bchjs.Util.floor2(amountQty * bchUsdPrice) + // amountUsd = oppositeQty + // amountBch = amountQty + } + + // Update app state + sendCardData.setOppositeQty(oppositeQty) + } catch (err) { + /* exit quietly */ + console.log('Error: ', err) + } + } + + // This is a click event handler that toggles the units between BCH and USD. + function handleSwitchUnits ({ sendCardData }) { + // Toggle the unit + let newUnit = '' + let oppositeUnits = '' + const oldUnit = sendCardData.amountUnits + if (oldUnit.includes('USD')) { + newUnit = 'BCH' + oppositeUnits = 'USD' + } else { + newUnit = 'USD' + oppositeUnits = 'BCH' + } + + // Clear the Amount text box + sendCardData.setAmountStr('') + sendCardData.setOppositeQty(0) + + // Persist the new units. + sendCardData.setAmountUnits(newUnit) + sendCardData.setOppositeUnits(oppositeUnits) + } + + // Add a new line to the waiting modal. + function addToModal (inStr, sendCardData) { + sendCardData.setModalBody(prevBody => { + prevBody.push(inStr) + return prevBody + }) + } + + // Send BCH based to the address in the form, and the amount specified in the + // form. + async function handleSendBch ({ sendCardData, appData }) { + console.log('Sending BCH') + try { + // Clear the modal body + sendCardData.setModalBody([]) + sendCardData.setHideSpinner(false) + + // Open the modal + sendCardData.setHideModal(false) + + let amountBch + if (sendCardData.amountUnits === 'USD') { + amountBch = sendCardData.oppositeQty + } else { + amountBch = parseFloat(sendCardData.amountStr) + } + console.log('amountBch: ', amountBch) + + if (amountBch < 0.00000546) throw new Error('Trying to send less than dust.') + + let bchAddr = sendCardData.bchAddr + let infoStr = `Sending ${amountBch} BCH ($${sendCardData.amountUsd} USD) to ${bchAddr}` + console.log(infoStr) + + // Update modal + addToModal('Preparing to send bch...', sendCardData) + + const wallet = appData.wallet + const bchjs = wallet.bchjs + + // If the address is an SLP address, convert it to a cash address. + if (bchAddr.includes('simpleledger:')) { + bchAddr = bchjs.SLP.Address.toCashAddress(bchAddr) + } + + // Convert the BCH to satoshis + const sats = bchjs.BitcoinCash.toSatoshi(amountBch) + + // Update the wallets UTXOs + infoStr = 'Updating UTXOs...' + console.log(infoStr) + addToModal(infoStr, sendCardData) + await wallet.getUtxos() + + const receivers = [{ + address: bchAddr, + amountSat: sats + }] + const txid = await wallet.send(receivers) + + // Display TXID + infoStr = `txid: ${txid}` + // console.log(infoStr) + // modalBody.push(infoStr) + addToModal(infoStr, sendCardData) + + // Link to block explorer + const explorerUrl = `https://blockchair.com/bitcoin-cash/transaction/${txid}` + const explorerLink = (Block Explorer) + // modalBody.push(explorerLink) + addToModal(explorerLink, sendCardData) + + sendCardData.setHideSpinner(true) + sendCardData.setBchAddr('') + sendCardData.setAmountStr('') + } catch (err) { + console.log('Error in handleSendBch(): ', err) + + sendCardData.setModalBody([`Error: ${err.message}`]) + sendCardData.setHideSpinner(true) + } + } + + return ( + <> + { + hideModal + ? null + : () + } + + + + + + +

Send

+
+
+ + + + + BCH Address: + + + + + +
e.preventDefault()}> + + setBchAddr(e.target.value)} + value={bchAddr} + /> + pasteFromClipboard()} + /> + +
+ + +
+
+ + + + Amount: + + + + + +
{ e.preventDefault(); handleSendBch({ sendCardData, appData }) }}> + + handleUpdateAmount({ event, appData, sendCardData })} + value={amountStr} + /> + +
+ +
+ + + Units : {amountUnits} + handleSwitchUnits({ sendCardData, appData })} + /> + + + {oppositeUnits} : {oppositeQty} + + +
+ + + + + + + +
+
+
+ + ) +} + +export default SendCard + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-send/refresh-balance.js`: + +```js +/* + This library exports a RefreshBalance functional Component and a + refreshBalance() function. + The RefreshBalance Component is rendered as a hidden Waiting modal. + When the refreshBalance() function is called, it causes the modal to + appear while the wallet balance is updated. Once updated, the modal is hidden + again. +*/ + +// Global npm libraries +import React, { useEffect, useState, useCallback } from 'react' + +// Local libraries +import WaitingModal from '../../waiting-modal' + +export default function RefreshBchBalance (props) { + // Dependency injections of props + const { ref } = props + + // State + const [showWaitingModal, setShowWaitingModal] = useState(false) + const [modalBody, setModalBody] = useState([]) + const [hideSpinner] = useState(false) + + // Add a new line to the waiting modal. + const addToModal = useCallback((inStr) => { + // console.log('addToModal() inStr: ', inStr) + setModalBody(prevBody => { + // console.log('prevBody: ', prevBody) + prevBody.push(inStr) + return prevBody + }) + }, []) + + // Update the balance of the wallet. + const handleRefreshBalance = useCallback(async (appData) => { + try { + setModalBody([]) + + // Throw up the waiting modal + setShowWaitingModal(true) + + addToModal('Updating wallet balance...') + + // Get handles on app data. + const walletState = appData.bchWalletState + const cashAddr = appData.bchWalletState.cashAddress + const wallet = appData.wallet + + // Get the latest balance of the wallet. + const newBalance = await wallet.getBalance({ bchAddress: cashAddr }) + + addToModal('Updating BCH per USD price...') + const bchUsdPrice = await wallet.getUsd() + + // Update the wallet state. + walletState.bchBalance = newBalance + walletState.bchUsdPrice = bchUsdPrice + appData.updateBchWalletState({ walletState, appData }) + + setShowWaitingModal(false) + setModalBody([]) + } catch (err) { + console.error('Error while trying to update BCH balance: ', err) + + addToModal([`Error: ${err.message}`]) + setShowWaitingModal(false) + } + }, [addToModal]) + + // add a ref to the handleRefreshBalance function + // This is used to call the function from the parent component. + useEffect(() => { + if (ref && !ref.current) ref.current = { handleRefreshBalance } + }, [ref, handleRefreshBalance]) + + return ( + <> + <> + {showWaitingModal && ( + + )} + + + ) +} + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-send/balance-card.js`: + +```js +/* + This card displays the users balance in BCH. +*/ + +// Global npm libraries +import React from 'react' +import { Container, Row, Col, Card } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCoins } from '@fortawesome/free-solid-svg-icons' + +const BalanceCard = (props) => { + const { appData } = props + + const bchjs = appData.wallet.bchjs + const sats = appData.bchWalletState.bchBalance + const bchBalance = bchjs.BitcoinCash.toBitcoinCash(sats) + const usdBalance = bchjs.Util.floor2(bchBalance * appData.bchWalletState.bchUsdPrice) + + return ( + <> + + + +

Balance

+
+
+ + + + + USD: ${usdBalance} + + + + + + BCH: {bchBalance} + + + + + + Satoshis: {sats} + + + +
+
+ + ) +} + +export default BalanceCard + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-send/receive-card.js`: + +```js +/* + This card displays the users BCH and SLP address and QR code +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Container, Row, Col, Card, Form } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faWallet } from '@fortawesome/free-solid-svg-icons' +import { QRCodeSVG } from 'qrcode.react' + +const ReceiveCard = ({ appData }) => { + const [addrSwitch, setAddrSwitch] = useState(false) + const [displayCopyMsg, setDisplayCopyMsg] = useState(false) + + // Determine which address to display + const addrToDisplay = !addrSwitch + ? appData.bchWalletState.cashAddress + : appData.bchWalletState.slpAddress + + // Copy the selected address to the clipboard when the QR image is clicked + const handleCopyAddress = async (value) => { + appData.appUtil.copyToClipboard(value) + + // Display the copied message + setDisplayCopyMsg(true) + + // Clear the copied message after some time + setTimeout(() => { + setDisplayCopyMsg(false) + }, 1000) + } + + // Event handler for address switch toggle + const handleAddrSwitchToggle = (event) => { + setAddrSwitch(event.target.checked) + } + + return ( + <> + + + +

Receive

+
+
+ + + + + {displayCopyMsg ? 'Copied' : null} + + + + + + { handleCopyAddress(addrToDisplay) }} + /> + + + + +

{addrToDisplay}

+ +
+ + + + +
+ handleAddrSwitchToggle(e)} + /> + + + +
+
+
+
+ + ) +} + +export default ReceiveCard + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-send/refresh-bch-balance-button.js`: + +```js +/* + This component is displayed as a button. When clicked, it loads the + RefreshBchBalance component, which renders a waiting modal while the wallet + balance is refreshed. +*/ + +// Global npm libraries +import React, { useRef } from 'react' +import { Button } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faRedo } from '@fortawesome/free-solid-svg-icons' + +// Local libraries +import RefreshBchBalance from './refresh-balance' + +function RefreshBchBalanceButton (props) { + // Dependency injections of props + const appData = props.appData + + // Child function references + const refreshBchBalanceRef = useRef() + + // Update the balance of the wallet. + async function handleButtonRefreshBalance (appData) { + // Call the child function + refreshBchBalanceRef.current.handleRefreshBalance(appData) + } + + return ( + <> + + + + + ) +} + +export default RefreshBchBalanceButton + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/configuration/index.js`: + +```js +/* + This component is a View that allows the user to handle configuration + settings for the app. +*/ + +// Global npm libraries +import React from 'react' +import ServerSelectView from './select-server-view' + +function ConfigurationView (props) { + const { appData } = props + + return ( + <> + + + ) +} + +export default ConfigurationView + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/configuration/select-server-button.js`: + +```js +/* + This component contains a drop-down form that lets the user select from + a range of Global Back End servers. +*/ + +// Global npm libraries +import React from 'react' +import { Container, Row, Col, Button } from 'react-bootstrap' +import { useNavigate } from 'react-router-dom' + +const ServerSelect = (props) => { + const { linkTo, appData } = props + + // Use the navigate function to navigate to the servers view + const navigate = useNavigate() + + // This is a click handler for the server select button. It brings up the + // server selection View. + const handleServerSelect = () => { + console.log('This function should navigate to the server selection view.') + navigate(linkTo) + } + + return ( + + <> +
+ + +
+
+ Having trouble loading? Try selecting a different back-end server. +
+

Current Server : {appData.serverUrl}

+ +
+ + +
+ +
+ ) +} + +export default ServerSelect + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/configuration/select-server-view.js`: + +```js +/* + This component is a View that allows the user to select a back end server + from a list of servers. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Row, Col, Form, Card } from 'react-bootstrap' + +function ServerSelectView (props) { + const { appData } = props + const [selectedServer, setSelectedServer] = useState(appData.serverUrl) + const servers = appData.servers + + // Update server when dropdown selection changes + const handleServerChange = (event) => { + setSelectedServer(event.target.value) + } + + const onSaveServer = (serverUrl) => { + console.log('server target: ', serverUrl) + appData.updateLocalStorage({ serverUrl }) + window.location.href = '/' + } + + return ( + <> + + + + + + + + + + +

Configuration

+

+ This page allows you to change configuration settings for different + back end services. This page is for advanced users only. +

+ +
+
+ + + +

+ Select an alternative server below. The app will reload and use + the selected server. +

+ + {servers.map((server, i) => ( + + ))} + + +
+ + + + + + +
+
+ + ) +} + +export default ServerSelectView + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/sign/index.js`: + +```js +/* +Component for signing a message with a WIF private key. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Container, Row, Col, Form, Button } from 'react-bootstrap' +import { faCopy } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +function SignMessage (props) { + // Convert class state to hooks + const { wallet, appUtil } = props.appData + + const [sign, setSign] = useState('') + const [msg, setMsg] = useState('') + const [bchAddr] = useState(wallet.walletInfo.cashAddress) + const [slpAddr] = useState(wallet.walletInfo.slpAddress) + const [err, setErr] = useState('') + const [copied, setCopied] = useState(false) + + const handleSignMessage = (event) => { + try { + event.preventDefault() + + if (!msg) throw new Error('Enter a message to sign.') + + const bchjs = wallet.bchjs + + const wif = props.appData.wallet.walletInfo.privateKey + const sig = bchjs.BitcoinCash.signMessageWithPrivKey(wif, msg) + + setSign(sig) + setErr('') + } catch (err) { + console.log('Error in handleSignMessage(): ', err) + setErr(err.message) + setSign('') + } + } + + // Function to copy the value to the clipboard. + const handleCopyToClipboard = async (value) => { + appUtil.copyToClipboard(value) + + // show the copied message + setCopied(true) + + // hide copied message after 1 second + setTimeout(function () { + setCopied(false) + }, 1000) + } + + const copyIcon = (value) => { + return handleCopyToClipboard(value)} style={{ cursor: 'pointer', marginLeft: '10px' }} /> + } + + return ( + <> + + + +

+ This view allows you cryptographically sign a message with your + wallet. These signatures are used in a wide range of applications, + such as gaining access to + the PSF VIP Telegram channel. +

+

+ Enter any message into the form below and click the button. This + view will generate a cryptographic signature. +

+ +
+ + +
+ + Enter a message to sign. + setMsg(e.target.value)} /> + + {err &&

{`Error: ${err}`}

} + + +
+ +
+
+ {sign && ( +
+ + +

+ Signature: {sign} {copyIcon(sign)} +

+

+ BCH Address: {bchAddr} {copyIcon(bchAddr)} +

+

+ SLP Address: {slpAddr} {copyIcon(slpAddr)} +

+ + +
+ {copied && ( + + Copied! + + )} +
+ )} +
+ + ) +} + +export default SignMessage + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/slp-tokens/send-token-button.js`: + +```js +/* + This component renders as a button. When clicked, it opens up a modal + for sending a quantity of tokens. + This component requires state, because it's a complex form that is being manipulated + by the user. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Button, Modal, Container, Row, Col, Form, Spinner } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPaperPlane, faPaste } from '@fortawesome/free-solid-svg-icons' + +function SendTokenButton ({ token, appData, refreshTokens }) { + // Convert class state to useState hooks + const [showAddrWarning, setShowAddrWarning] = useState(false) + const [showModal, setShowModal] = useState(false) + const [statusMsg, setStatusMsg] = useState('') + const [hideSpinner, setHideSpinner] = useState(true) + const [shouldRefreshOnModalClose, setShouldRefreshOnModalClose] = useState(false) + const [sendToAddress, setSendToAddress] = useState('') + const [sendQtyStr, setSendQtyStr] = useState('') + const [dialogFinished, setDialogFinished] = useState(true) + + // Handler functions + const handleShowModal = () => setShowModal(true) + + const handleCloseModal = async () => { + if (!dialogFinished) return + + if (shouldRefreshOnModalClose) { + setShowModal(false) + setShouldRefreshOnModalClose(false) + setStatusMsg('') + await refreshTokens() + } else { + setShowModal(false) + setStatusMsg('') + setSendToAddress('') + setSendQtyStr('') + } + } + const handleUpdateSendToAddr = (event) => { + const value = event.target.value + setSendToAddress(value) + setShowAddrWarning(value.includes('bitcoincash')) + } + const handleGetMax = () => { + setSendQtyStr(token.qty) + } + + // Click handler that fires when the user clicks the 'Send' button. + + const handleSendTokens = async (e) => { + e.preventDefault() + try { + setStatusMsg('Preparing to send tokens...') + setHideSpinner(false) + setDialogFinished(false) + setShowAddrWarning(false) + + // Validate the quantity + const qty = parseFloat(sendQtyStr) + if (isNaN(qty)) throw new Error('Invalid send quantity') + + const wallet = appData.wallet + const bchjs = wallet.bchjs + + // Validate the address + let addr = sendToAddress + if (addr.includes('simpleledger')) { + addr = bchjs.SLP.Address.toCashAddress(addr) + } + if (!addr.includes('bitcoincash')) throw new Error('Invalid address') + + let infoStr = 'Updating UTXOs...' + + setStatusMsg(infoStr) + await wallet.getUtxos() + + const receiver = [{ + address: addr, + tokenId: token.tokenId, + qty + }] + + infoStr = 'Generating and broadcasting transaction...' + setStatusMsg(infoStr) + + const txid = await wallet.sendTokens(receiver, 3) + console.log(`Token sent. TXID: ${txid}`) + + setStatusMsg(

Success! See on Block Explorer

) + setHideSpinner(true) + setSendQtyStr('') + setSendToAddress('') + setShouldRefreshOnModalClose(true) + setDialogFinished(true) + } catch (err) { + console.error('Error in handleSendTokens(): ', err) + setStatusMsg(`Error sending tokens: ${err.message}`) + setHideSpinner(true) + setDialogFinished(true) + } + } + + // Load address from clipboard + const pasteFromClipboard = async () => { + try { + const address = await appData.appUtil.readFromClipboard() + setSendToAddress(address) + } catch (err) { + console.warn('Error pasting from clipboard: ', err) + } + } + + // Modal JSX + const getModal = () => { + return ( + + + Send Tokens: {token.ticker} + + + + {/* ... existing Modal.Body content ... */} + + + SLP Address: + + + + + +
e.preventDefault()}> + + + +
+ + + + + +
+
+ + + Amount: + + + + + +
+ + setSendQtyStr(e.target.value)} + value={sendQtyStr} + /> + +
+ + + + + +
+
+ + + + + + +
+ + {showAddrWarning && ( + <> + + +

+ Warning: Careful! Not all Bitcoin Cash wallets are token-aware. + If you send this token to a wallet that is not + token-aware, it could be burned. It's best practice to + only send tokens to 'simpleledger' addresses and not + 'bitcoincash' addresses. +

+ +
+
+ + )} + + + {statusMsg} + + + + {!hideSpinner && } + + + +
+
+ +
+ ) + } + + return ( + <> + + {showModal && getModal()} + + ) +} + +export default SendTokenButton + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/slp-tokens/index.js`: + +```js +/* + This is the 'Token View'. It displays the SLP tokens in the wallet. +*/ + +// Global npm libraries +import React, { useState, useEffect, useCallback } from 'react' +import { Container, Row, Col, Spinner } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons' + +// Local libraries +import TokenCard from './token-card' +import RefreshTokenBalance from './refresh-tokens' + +const SlpTokens = (props) => { + const [appData, setAppData] = useState(props.appData) + const [iconsAreLoaded, setIconsAreLoaded] = useState(false) + const [tokens, setTokens] = useState([]) + + const refreshTokenButtonRef = React.useRef() + + // Update the tokens state when the appData changes + useEffect(() => { + setTokens(appData.bchWalletState.slpTokens) + }, [appData]) + + // This function is triggered when the token balance needs to be refreshed + // from the blockchain. + // This needs to happen after sending a token, to reflect the changed balance + // within the wallet app. + // This function triggers the on-click function within the refresh-tokens.js button. + const refreshTokens = async () => { + const newAppData = await refreshTokenButtonRef.current.handleRefreshTokenBalance() + setAppData(newAppData) + } + + // Get Cid from url + const parseCid = (url) => { + // get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq + if (url && url.includes('ipfs://')) { + const cid = url.split('ipfs://')[1] + return cid + } + return url + } + + // Fetch mutable data if it exist and get the token icon url + const fetchTokenIcon = useCallback(async (token) => { + try { + // Get the token data + const tokenData = await appData.wallet.getTokenData(token.tokenId) + if (!tokenData.mutableData) return false // Return false if no mutable data + // Get the token icon from the mutable data + const cid = parseCid(tokenData.mutableData) + console.log('mutable data cid', cid) + + const { json } = await appData.wallet.cid2json({ cid }) + + if (!json) return false + + const iconUrl = json.tokenIcon + // Return icon url + return iconUrl + } catch (error) { + return false + } + }, [appData]) + + // This function loads the token icons from the ipfs gateways. + const lazyLoadTokenIcons = useCallback(async () => { + try { + setIconsAreLoaded(false) + + const tokens = appData.bchWalletState.slpTokens + + setTokens(tokens) // update token state + + // map each token and fetch the icon url + for (let i = 0; i < tokens.length; i++) { + const thisToken = tokens[i] + + // Incon does not need to be downloaded, so continue with the next one + if (thisToken.iconAlreadyDownloaded) continue + + // Try to get token icon url from mutable data. + const iconUrl = await fetchTokenIcon(thisToken) + console.log('iconUrl', iconUrl) + if (iconUrl) { + // Set the icon url to the token , this can be used to display the icon in the token card component. + thisToken.icon = iconUrl + } + + // Mark token to prevent fetch token icon again. + thisToken.iconAlreadyDownloaded = true + } + + appData.updateBchWalletState({ walletObj: { slpTokens: tokens }, appData }) + setIconsAreLoaded(true) + } catch (error) { + setIconsAreLoaded(true) + } + }, [appData, fetchTokenIcon]) + + // Start to load the token icons when the component is mounted + useEffect(() => { + lazyLoadTokenIcons() + }, [lazyLoadTokenIcons]) + + // Generate the token cards for each token in the wallet. + const generateCards = () => { + const tokens = appData.bchWalletState.slpTokens + return tokens.map(thisToken => ( + + )) + } + + return ( + <> + + + + + + + + + + + + + + { + !iconsAreLoaded && ( +
+ Loading Token Icons + +
+ ) + } + + +
+
+ + + {generateCards()} + + {/** Display a message if no tokens are found */} + {tokens.length === 0 && ( + + No tokens found in wallet + + )} + +
+ + ) +} + +export default SlpTokens + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/slp-tokens/info-button.js`: + +```js +/* + This component renders as a button. When clicked, it opens a modal that + displays information about the token. + + This is a functional component with as little state as possible. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Button, Modal, Container, Row, Col } from 'react-bootstrap' + +// Takes a string as input. If it matches a pattern for a link, a JSX object is +// returned with a link. Otherwise the original string is returned. +function linkIfUrl (url) { + // Convert the URL into a link if it contains 'http' + if (url.includes('http')) { + url = ({url}) + + // + } else if (url.includes('ipfs://')) { + // Convert to a Filecoin link if its an IPFS reference. + + const cid = url.substring(7) + url = ({url}) + } + + return url +} + +function InfoButton (props) { + const [show, setShow] = useState(false) + + const handleClose = () => { + setShow(false) + // props.instance.setState({ showModal: false }) + } + + const handleOpen = () => { + setShow(true) + } + + // Convert the url property of the token to a link, if it matches common patterns. + let url = props.token.url + url = linkIfUrl(props.token.url) + + // console.log('props.token: ', props.token) + + return ( + <> + + + + Token Information + + + + + Ticker: + {props.token.ticker} + + + + Name: + {props.token.name} + + + + Token ID: + + + {props.token.tokenId} + + + + + + Decimals: + {props.token.decimals} + + + + Token Type: + {props.token.tokenType} + + + + URL: + {url} + + + + + + + ) +} + +export default InfoButton + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/slp-tokens/token-card.js`: + +```js +/* + This Card component summarizes an SLP token. + if a token icon does not exist or cant be loaded , then display a default icon from Jdenticon library. +*/ + +// Global npm libraries +import React, { useState, useEffect } from 'react' +import { Container, Row, Col, Card } from 'react-bootstrap' +import Jdenticon from '@chris.troutner/react-jdenticon' +// Local libraries +import InfoButton from './info-button' +import SendTokenButton from './send-token-button' + +function TokenCard (props) { + const { token } = props + const [icon, setIcon] = useState(token.icon) + + // Update icon state every token.icon changes + useEffect(() => { + setIcon(token.icon) + }, [token.icon]) + + return ( + <> + + + + {/** If the icon is loaded, display it */ + icon && ( + { + setIcon(null) // Set the icon to null if it fails to load the image url. + }} + /> + ) + } + + {/** If the icon is not loaded, display the Jdenticon */ + !icon && ( + + ) + } + +

{props.token.ticker}

+
+ + + + + {props.token.name} + + +
+ + + Balance: + {props.token.qty} + +
+ + + + + + + + + +
+
+
+ + + ) +} + +export default TokenCard + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/slp-tokens/refresh-tokens.js`: + +```js +/* + This component is displayed as a button. When clicked, it displays a modal + with a spinny gif, while the wallets SLP token list is updated from the + blockchain and psf-slp-indexer. +*/ + +// Global npm libraries +import React, { useState, useEffect, useCallback } from 'react' +import { Button } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faRedo } from '@fortawesome/free-solid-svg-icons' + +// Local libraries +import WaitingModal from '../../waiting-modal' + +function RefreshTokenBalance ({ appData: initialAppData, ref, lazyLoadTokenIcons }) { + const [appData, setAppData] = useState(initialAppData) + const [modalBody, setModalBody] = useState([]) + const [hideSpinner, setHideSpinner] = useState(false) + const [hideWaitingModal, setHideWaitingModal] = useState(true) + + // Add a new line to the waiting modal. + const addToModal = (inStr) => { + setModalBody(prevBody => [...prevBody, inStr]) + } + + // Update the balance of the wallet. + const handleRefreshTokenBalance = useCallback(async () => { + try { + // Throw up the waiting modal + setHideWaitingModal(false) + addToModal('Updating token balance...') + + // Get handles on app data. + const walletState = appData.bchWalletState + const wallet = appData.wallet + + // Update the wallet UTXOs + await wallet.initialize() + const tokenList = await wallet.listTokens() + + // Copy tokens from old token state. + for (let i = 0; i < tokenList.length; i++) { + const thisToken = tokenList[i] + + // Look through the existing wallet state for the matching token. + const existingToken = walletState.slpTokens.filter(x => x.tokenId === thisToken.tokenId) + + // If the current wallet state has an icon, copy it over. + if (existingToken[0] && existingToken[0].icon) { + thisToken.icon = existingToken[0].icon + } + } + + // Update the wallet state. + walletState.slpTokens = tokenList + + appData.updateBchWalletState({ walletObj: walletState, appData }) + + const newAppData = { ...appData, bchWalletState: walletState } + // Update state + setHideWaitingModal(true) + setAppData(newAppData) + setModalBody([]) + + // Lazy load icons for any new tokens. + await lazyLoadTokenIcons() + + return newAppData + } catch (err) { + console.error('Error while trying to update BCH balance: ', err) + setModalBody([`Error: ${err.message}`]) + setHideSpinner(true) + } + }, [appData, lazyLoadTokenIcons]) + + // add a ref to the handleRefreshBalance function + // This is used to call the function from the parent component. + useEffect(() => { + if (ref && !ref.current) ref.current = { handleRefreshTokenBalance } + }, [ref, handleRefreshTokenBalance]) + + return ( + <> + + + {!hideWaitingModal && ( + + )} + + ) +} + +export default RefreshTokenBalance + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/placeholder2.js`: + +```js +/* + This is a placeholder View +*/ + +// Global npm libraries +import React, { useEffect } from 'react' + +function Placeholder2 (props) { + useEffect(() => { + console.log('Placeholder 2 loaded.') + }, []) + + return ( + <> +

This is placeholder View #2

+ + ) +} + +export default Placeholder2 + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-wallet/optimize-wallet.js`: + +```js +/* + This component allows the user to optimize their wallet by consolidating + UTXOs. This speeds up all the network calls and results in an improved UX. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Container, Row, Col, Card, Button } from 'react-bootstrap' + +// Local libraries +import WaitingModal from '../../waiting-modal' + +function OptimizeWallet (props) { + // State + const [showModal, setShowModal] = useState(false) + const [modalBody, setModalBody] = useState([]) + const [hideSpinner, setHideSpinner] = useState(false) + const [denyClose, setDenyClose] = useState(false) + + // Get props values + const { wallet } = props.appData + + // Optimize wallet + const handleOptimize = async () => { + console.log('Optimize Wallet button clicked.') + // Show waiting modal + setShowModal(true) + setModalBody(['Optimizing wallet...']) + setDenyClose(true) + + // Optimize wallet + await wallet.optimize() + + // Show success modal + setShowModal(true) + setModalBody(['Your wallet has been optimized!']) + setDenyClose(false) + setHideSpinner(true) + + try { + // Get all UTXOs in the wallet + const utxos = wallet.utxos.utxoStore + console.log('utxos: ', utxos) + + // Add up all the UTXOs + const bchUtxoCnt = utxos.bchUtxos.length + let fungibleUtxoCnt = utxos.slpUtxos.type1.tokens.length + if (!fungibleUtxoCnt) fungibleUtxoCnt = 0 + let nftUtxoCnt = utxos.slpUtxos.nft.length + if (!nftUtxoCnt) nftUtxoCnt = 0 + const totalUtxos = bchUtxoCnt + fungibleUtxoCnt + nftUtxoCnt + console.log(`bchUtxoCnt: ${bchUtxoCnt}, fungibleUtxoCnt: ${fungibleUtxoCnt}, nftUtxoCnt: ${nftUtxoCnt}`) + console.log(`total UTXO count: ${totalUtxos}`) + + if (totalUtxos > 10) { + const newModalBody = [ + 'Your wallet has been optimized!', + 'Your wallet still has more than 10 UTXOs. Increased numbers of UTXOs slow down performance. If you have several tokens in your wallet, it is recommended that you store them in a paper wallet. Here is a video explaining how to do that:' + ] + + newModalBody.push(Video: How to Store SLP Tokens on a Paper Wallet) + newModalBody.push(Generate a Paper Wallet) + + setModalBody(newModalBody) + } + } catch (err) { + console.log('Error while trying to count total number of UTXOs: ', err) + } + } + + return ( + <> + + + + + + +

Optimize Wallet

+
+ + + Clicking the button below will optimize your wallet and make it + function faster. +

+ How it works: By consolidating + as many UTXOs in your wallet as possible, it reduces the total + number of UTXOs in your wallet. Fewer UTXOs in your wallet make + all network calls faster, and results in an improved user experience. +

+ +
+
+
+ +
+
+ + {showModal && ( + + )} + + ) +} + +export default OptimizeWallet + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-wallet/index.js`: + +```js +/* + This component controlls the Wallet View. +*/ + +// Global npm libraries +import React from 'react' +import { Container, Row, Col } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons' + +// Local Libraries +import WebWalletWarning from './warning' +import WalletSummary from './wallet-summary' +import WalletClear from './clear-wallet' +import WalletImport from './import-wallet' +import OptimizeWallet from './optimize-wallet' + +function BchWallet (props) { + // Dependency injection through props + const appData = props.appData + console.log('appData: ', appData) + + return ( + <> + + + + + + + + + + +
+ +
+ +
+ +
+ + + ) +} + +export default BchWallet + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-wallet/clear-wallet.js`: + +```js +/* +This Card component is used to clear the Local Storage and reset the wallet. +*/ + +// Global npm libraries +import React from 'react' +import { Container, Row, Col, Card, Button } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons' + +const WalletClear = (props) => { + const { removeLocalStorageItem } = props.appData + + // Delete wallet data from Local Storage and reload the app. + const handleClearLocalStorage = () => { + console.log('Deleting wallet and reloading page.') + // Delete the mnemonic from Local Storage + removeLocalStorageItem('mnemonic') + // Reload the app. + window.location.reload() + } + + return ( + <> + + + + + + +

+ {' '} + Clear Local Storage +

+
+ + + Clicking the button below will clear the Local Storage, which + will reload the app with a newly created wallet. +
+ + Be sure to write down your 12-word mnemonic to back + up your wallet before clicking the button! + . +

+ +
+
+
+ +
+
+ + ) +} + +export default WalletClear + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-wallet/wallet-summary.css`: + +```css +.blurred { + filter: blur(6px); + -webkit-filter: blur(6px); +} + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-wallet/import-wallet.js`: + +```js +/* + This component allows the user to import a new wallet using a 12-word mnemonic. +*/ + +// Global npm libraries +import React, { useCallback } from 'react' +import { Container, Row, Col, Card, Button, Form } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faFileExport, faPaste } from '@fortawesome/free-solid-svg-icons' +// import { Clipboard } from '@capacitor/clipboard' + +const WalletImport = (props) => { + const [newMnemonic, setNewMnemonic] = React.useState('') + const { appData } = props + + // Load mnemonic from clipboard + const pasteFromClipboard = async () => { + try { + const mnemonic = await appData.appUtil.readFromClipboard() + setNewMnemonic(mnemonic) + } catch (err) { + console.warn('Error pasting from clipboard: ', err) + } + } + + // Handle input change for mnemonic + const handleImportMnemonic = async (event) => { + const inputStr = event.target.value + const formattedInput = inputStr.toLowerCase() + setNewMnemonic(formattedInput) + } + + // Ensure the mnemonic is valid. If it is, then replace the current mnemonic + // in LocalStorage and reload the page. + const handleImportWallet = useCallback(async (event) => { + try { + const mnemonic = newMnemonic + const wallet = appData.wallet + const bchjs = wallet.bchjs + + // Verify the mnemonic is valid. + const isValid = bchjs.Mnemonic.validate(mnemonic, bchjs.Mnemonic.wordLists().english) + if (isValid.includes('is not in wordlist')) { + console.log('Mnemonic is NOT valid') + } else { + console.log('Mnemonic is valid') + } + + // Replace the old mnemonic in LocalStorage with the new one. + appData.updateLocalStorage({ mnemonic }) + // Reload the app. + window.location.reload() + } catch (error) { + console.warn('Error importing wallet: ', error) + } + }, [newMnemonic, appData]) + + return ( + <> + + + + + + +

+ {' '} + Import Wallet +

+
+ + + Enter a 12 word mnemonic below to import your wallet into + this app. The app will reload and use the new mnemonic. + + + + + +
e.preventDefault()}> + + + pasteFromClipboard(e)} + style={{ cursor: 'pointer' }} + /> + +
+ +
+ + + + + + + +
+
+
+
+ +
+
+ + ) +} + +export default WalletImport + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-wallet/wallet-summary.js`: + +```js +/* + This component displays a summary of the wallet. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Container, Row, Col, Card } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faWallet, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons' +// import { Clipboard } from '@capacitor/clipboard' + +// Local libraries +import './wallet-summary.css' +import CopyOnClick from './copy-on-click' + +function WalletSummary (props) { + // Props + const appData = props.appData + + const bchWalletState = appData.bchWalletState + console.log('wallet summary state: ', bchWalletState) + + // State + const [blurredMnemonic, setBlurredMnemonic] = useState(true) + const [blurredPrivateKey, setBlurredPrivateKey] = useState(true) + + // Encapsulate component state into an object that can be passed to child functions + const walletSummaryData = { + blurredMnemonic, + setBlurredMnemonic, + blurredPrivateKey, + setBlurredPrivateKey + } + + // Eye icon state + const eyeIcon = { + mnemonic: blurredMnemonic ? faEyeSlash : faEye, + privateKey: blurredPrivateKey ? faEyeSlash : faEye + } + + // Toggle the state of blurring for the mnemonic + const toggleMnemonicBlur = (inObj = {}) => { + try { + const { walletSummaryData } = inObj + + // toggle the state of blurring + const blurredState = walletSummaryData.blurredMnemonic + walletSummaryData.setBlurredMnemonic(!blurredState) + } catch (error) { + console.error('Error toggling mnemonic blur: ', error) + } + } + + // Toggle the state of blurring for the private key + const togglePrivateKeyBlur = (inObj = {}) => { + try { + const { walletSummaryData } = inObj + + // toggle the state of blurring + const blurredState = walletSummaryData.blurredPrivateKey + walletSummaryData.setBlurredPrivateKey(!blurredState) + } catch (error) { + console.error('Error toggling private key blur: ', error) + } + } + + return ( + <> + + + + + + +

+ {' '} + My Wallet +

+
+ + + + Mnemonic: {bchWalletState.mnemonic} + + + toggleMnemonicBlur({ walletSummaryData })} + /> + + + + + + + + + Private Key: {bchWalletState.privateKey} + + + togglePrivateKeyBlur({ walletSummaryData })} + /> + + + + + + + + + Cash Address: {bchWalletState.cashAddress} + + + + + + + + + + SLP Address: {bchWalletState.slpAddress} + + + + + + + + + + Legacy Address: {bchWalletState.legacyAddress} + + + + + + + + + + HD Path: {bchWalletState.hdPath} + + + + + + + +
+
+ + +
+
+ + ) +} + +export default WalletSummary + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-wallet/warning.js`: + +```js +/* + This component is a visual warning against storing large sums of money in + a web wallet. +*/ + +// Global npm libraries +import React from 'react' +import { Container, Row, Col, Card } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons' + +const WebWalletWarning = () => { + return ( + <> + + + + + + +

+ {' '} + Web Wallets are Insecure +

+
+ + + This is an open source, non-custodial web wallet + supporting Bitcoin Cash (BCH) and SLP tokens. + It is optimized for convenience and not security. +
+ Do not store large amounts of money on a web wallet. + +

+ Note: Scammers frequently copy this open source code to build + apps for stealing people's money. Be sure you trust the source + serving you this app. +
+
+
+ + +
+
+ + ) +} + +export default WebWalletWarning + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/bch-wallet/copy-on-click.js`: + +```js +/* + This component is visually represented with a copy icon. A wallet property + is passed as a prop. When clicked, the wallet property is copied to the + system clipboard. +*/ + +// Global npm libraries +import React, { useCallback, useState } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCopy } from '@fortawesome/free-solid-svg-icons' + +const CopyOnClick = (props) => { + // State + const [iconVis, setIconVis] = useState(true) + // Props + const { appData, walletProp, value } = props + // App Util + const { appUtil } = appData + + // Function to copy the value to the clipboard. + const handleCopyToClipboard = useCallback(async (event) => { + appUtil.copyToClipboard(value) + + // hide icon in order to show the copied message + setIconVis(false) + + // restart icon visibility after 1 second + setTimeout(function () { + setIconVis(true) + }, 1000) + }, [value, appUtil]) + + return ( + <> + {iconVis && ( + handleCopyToClipboard(e)} + style={{ cursor: 'pointer' }} + /> + )} + {!iconVis && ( + + Copied! + + )} + + ) +} + +export default CopyOnClick + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/sweep/index.js`: + +```js +/* + This Sweep component allows users to sweep a private key and transfer any + BCH or SLP tokens into their wallet. +*/ + +// Global npm libraries +import React from 'react' +import { Container, Row, Col, Form, Button, Modal, Spinner } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons' +import Sweeper from 'bch-token-sweep' + +// let _this + +const SweepWif = (props) => { + const { appData } = props + console.log('appData', appData) + const [wifToSweep, setWifToSweep] = React.useState('') + const [showModal, setShowModal] = React.useState(false) + const [statusMsg, setStatusMsg] = React.useState('') + const [hideSpinner, setHideSpinner] = React.useState(false) + + // shouldRefreshOnModalClose: false + + // Helper function to validate WIF + const validateWIF = (WIF) => { + if (typeof WIF !== 'string') return false + if (WIF.length !== 52) return false + if (WIF[0] !== 'L' && WIF[0] !== 'K') return false + return true + } + + // Update wallet state function + const updateWalletState = async () => { + const wallet = appData.wallet + const bchBalance = await wallet.getBalance({ bchAddress: wallet.walletInfo.cashAddress }) + await wallet.initialize() + const slpTokens = await wallet.listTokens(wallet.walletInfo.cashAddress) + appData.updateBchWalletState({ walletObj: { bchBalance, slpTokens }, appData }) + } + + // Handle sweep function + const handleSweep = async (e) => { + e.preventDefault() + try { + console.log(`Sweeping this WIF: ${wifToSweep}`) + + // Set modal initial state + setShowModal(true) + setHideSpinner(false) + setStatusMsg('') + + // Input validation + const isWIF = validateWIF(wifToSweep) + if (!isWIF) { + setHideSpinner(true) + setStatusMsg(Input is not a WIF private key.) + return + } + + try { + const walletWif = appData.wallet.walletInfo.privateKey + const toAddr = appData.wallet.slpAddress + + // Instance the Sweep library + const sweep = new Sweeper(wifToSweep, walletWif, appData.wallet) + await sweep.populateObjectFromNetwork() + + // Constructing the sweep transaction + const hex = await sweep.sweepTo(toAddr) + const txid = await appData.wallet.ar.sendTx(hex) + + // Generate status message + const newStatusMsg = ( + <> +

Sweep succeeded!

+

Transaction ID: {txid}

+

+ + TX on Blockchair BCH Block Explorer + +

+

+ + TX on token explorer + +

+ + ) + + setHideSpinner(true) + setStatusMsg(newStatusMsg) + setWifToSweep('') + + await updateWalletState() + } catch (err) { + setHideSpinner(true) + setStatusMsg({`Error: ${err.message}`}) + } + } catch (err) { + console.error('Error in handleSweep(): ', err) + } + } + + // Modal component + const getModal = () => ( + setShowModal(false)}> + + Sweeping... + + + + + {!hideSpinner && ( + + Sweeping private key... + + )} + +
+ {statusMsg && ( + + {statusMsg} + + )} +
+
+ +
+ ) + + return ( + <> + + + + + + + + + + + +

+ This View is used to 'sweep' a private key. This will transfer + any BCH or SLP tokens from a paper wallet to your web wallet. + Paper wallets are used to store BCH and tokens. You + can generate paper wallets here. +

+

+ Paste the private key of a paper wallet below and click the button + to sweep the funds. The private key must be in WIF format. It will + start with the letter 'K' or 'L'. +

+ +
+ + + +
+ + setWifToSweep(e.target.value)} + value={wifToSweep} + /> + +
+ +
+
+ + + + + + +
+ {showModal && getModal()} + + ) +} + +export default SweepWif + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/app-body/placeholder3.js`: + +```js +/* + This is a placeholder View +*/ + +// Global npm libraries +import React, { useEffect } from 'react' + +function Placeholder3 (props) { + useEffect(() => { + console.log('Placeholder 3 loaded.') + }, []) + + return ( + <> +

This is placeholder View #3

+ + ) +} + +export default Placeholder3 + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/starter-views.js`: + +```js +/** + * This file contains the views that are displayed before and after the BCH wallet is initialized. + */ +import React from 'react' +import WaitingModal from './waiting-modal' +import AppBody from './app-body' +// This is rendered *before* the BCH wallet is initialized. +export function UninitializedView (props = {}) { + // console.log('UninitializedView props: ', props) + const { appData } = props + + const heading = 'Connecting to BCH blockchain...' + + return ( + <> + + { + appData.asyncInitFinished + ? + : null + } + + ) +} + +// This is rendered *after* the BCH wallet is initialized. +export function InitializedView (props) { + const { appData } = props + + return ( + <> +
+ + + ) +} + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/waiting-modal/index.js`: + +```js +/* + This 'Waiting Modal' component displays a spinner animation and a status log. + It's used to inform the user that the app is waiting for something, and to + display progress. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Container, Row, Col, Modal, Spinner } from 'react-bootstrap' + +function ModalTemplate (props) { + // State + const [show, setShow] = useState(true) + + // Dependency injection of props + const denyClose = props.denyClose // Determins if user is allowed to close modal. + const closeFunc = props.closeFunc // Optional function called after modal is closed. + const heading = props.heading // Title of the modal + const body = props.body // Body of the modal + const hideSpinner = props.hideSpinner // Hide the animated spinner + + // This function is called when the modal is closed + const handleClose = () => { + console.log(`props.denyClose: ${denyClose}`) + if (denyClose) return + + setShow(false) + + if (closeFunc) { + closeFunc() + } + } + // const handleShow = () => setShow(true) + + return ( + + + {heading} + + + + + + + {hideSpinner ? null : } + + + + + + + ) +} + +// This function populates the body of the modal. It expects props.body to be +// an array of strings. +function BodyList (props) { + const items = props.body + // console.log('BodyList items: ', items) + + const listItems = [] + + // Paragraphs + for (let i = 0; i < items.length; i++) { + listItems.push(

{items[i]}

) + } + + return ( + listItems + ) +} + +// export default WaitingModal +export default ModalTemplate + +``` + +`/home/trout/work/psf/code/bch-wallet-web3-spa/src/components/load-scripts.js`: + +```js +/* + Load