Serve the shared Mempool explorer through the Angor indexer

This commit is contained in:
archipelago
2026-10-01 16:03:19 -04:00
parent 19c49c6605
commit 3acefecc24
9 changed files with 643 additions and 21 deletions
+55 -3
View File
@@ -1,7 +1,8 @@
# Angor Indexer # Angor Indexer
Headless mainnet API endpoint for Angor. The service reuses this node's Mempool Mainnet indexer endpoint for Angor, serving the existing Mempool explorer at
backend and Electrum index instead of creating a second blockchain database. the same origin. The service reuses this node's Mempool frontend/backend and
Electrum index instead of creating another explorer or blockchain database.
An unpruned, fully synced Bitcoin node is required. Installing against a pruned An unpruned, fully synced Bitcoin node is required. Installing against a pruned
node must show the existing archival-node requirement; it must never silently node must show the existing archival-node requirement; it must never silently
unprune or replace its Bitcoin data. unprune or replace its Bitcoin data.
@@ -32,12 +33,45 @@ Install **Angor Relay** separately to host project metadata locally, then add
clients. Its storage and configuration are separate from the node's internal clients. Its storage and configuration are separate from the node's internal
relay; installing or uninstalling it does not change the internal relay. relay; installing or uninstalling it does not change the internal relay.
## Verify the complete client flow
The root URL opens the Mempool explorer. `/health` and fee
estimates establish API availability; they do not prove that project discovery,
address history, or browser CORS works. Test a known funded project's address
history, its original Nostr announcement, the Explore page, and project details
in the actual Angor client. A certificate alone does not establish public routing.
Keep existing discovery relays when adding a new relay. A new relay has no
historical project data and does not automatically replicate other relays.
Even with existing relays, an empty Explore page can be a client discovery
failure: Angor Hub v2.0.0 was observed to stop after a batch whose announcements
all failed on-chain validation. The same failure reproduced with our indexer
and Angor's public indexer. Do not bypass the funding transaction's event-ID
commitment or substitute an unsigned announcement to make a project appear.
For opt-in read-only browser acceptance, install the frontend test dependencies
and Playwright Chromium, then run:
```sh
ANGOR_TEST_INDEXER=https://indexer.example.com/ \
ANGOR_TEST_RELAY=wss://relay.example.com/ \
ANGOR_TEST_RELAYS='["wss://relay.angor.io","wss://relay.example.com/"]' \
node tests/lifecycle/angor-public-browser.cjs
```
The relay under test must already contain the known original public project
announcement documented in the test. The test does not import events, send
funds, change your browser profile, or disable TLS verification. It checks the
funding transaction/event commitment and real browser discovery and details.
Relay signed writes, invalid-signature rejection, persistence, full node sync,
and proxy upgrade/renewal tests remain separate acceptance requirements.
## Packaging ## Packaging
Build the pinned image with: Build the pinned image with:
``` ```
podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.1 apps/angor-indexer/container podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.2 apps/angor-indexer/container
``` ```
The image runs as UID 101 with a read-only root filesystem and no capabilities. The image runs as UID 101 with a read-only root filesystem and no capabilities.
@@ -55,3 +89,21 @@ address query is indexed at the latest Bitcoin tip.
Install Mempool Explorer first. The declarative `install_prerequisites` check Install Mempool Explorer first. The declarative `install_prerequisites` check
refuses a new adapter installation if its Mempool API component is absent, before refuses a new adapter installation if its Mempool API component is absent, before
creating an installed-app record. It does not install or resync Bitcoin for you. creating an installed-app record. It does not install or resync Bitcoin for you.
## Explorer on the public indexer origin
The linked official deployment guide exposes **Mempool frontend and API together**
on the public indexer URL. It uses standard Mempool images and requires no custom
Angor fork or `ANGOR_ENABLED` flag.
The operator now requires that same browser experience: opening the configured
indexer domain must show the existing Mempool explorer, while Angor API requests
continue working on that origin. Reuse the existing Mempool stack, including its
live WebSocket feed; do not install a second explorer or blockchain database.
**Candidate 1.0.2:** `/` and frontend paths proxy to the existing Mempool
frontend; `/api/`, `/api/v1/`, `/health` and the WebSocket feed retain their
indexer routes. Version 1.0.1 served only service JSON at `/`. The candidate
remains pending deployment/release acceptance, which must cover assets and deep links,
desktop/mobile rendering, WebSocket updates, API/CORS/broadcast, trusted HTTPS,
restart/upgrade and management-access isolation before documenting it as shipped.
+24 -6
View File
@@ -15,6 +15,14 @@ http {
zone mempool_backend 64k; zone mempool_backend 64k;
server mempool-api:8999 resolve; server mempool-api:8999 resolve;
} }
upstream mempool_frontend {
zone mempool_frontend 64k;
server mempool:8080 resolve;
}
map $http_upgrade $angor_connection_upgrade {
default upgrade;
'' close;
}
server { server {
listen 8080; listen 8080;
client_max_body_size 4m; client_max_body_size 4m;
@@ -23,7 +31,8 @@ http {
proxy_send_timeout 30s; proxy_send_timeout 30s;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header Connection ""; proxy_set_header Connection $angor_connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Authorization ""; proxy_set_header Authorization "";
proxy_set_header Cookie ""; proxy_set_header Cookie "";
proxy_hide_header Access-Control-Allow-Origin; proxy_hide_header Access-Control-Allow-Origin;
@@ -35,10 +44,6 @@ http {
# Mempool's backend uses /api/v1. Match its frontend's shorter /api # Mempool's backend uses /api/v1. Match its frontend's shorter /api
# surface too, without doubling already-versioned Angor URLs. # surface too, without doubling already-versioned Angor URLs.
rewrite ^/api/(?!v1/)(.*)$ /api/v1/$1 last; rewrite ^/api/(?!v1/)(.*)$ /api/v1/$1 last;
location = / {
default_type application/json;
return 200 '{"service":"Angor Indexer","network":"mainnet","api":"/api/v1","health":"/health"}\n';
}
# Readiness checks the indexing backend, not this gateway's process. # Readiness checks the indexing backend, not this gateway's process.
location = /health { location = /health {
limit_except GET { deny all; } limit_except GET { deny all; }
@@ -54,10 +59,23 @@ http {
limit_except GET POST { deny all; } limit_except GET POST { deny all; }
proxy_pass http://mempool_backend; proxy_pass http://mempool_backend;
} }
location = /api/v1/ws {
limit_except GET { deny all; }
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_pass http://mempool_backend;
}
location /api/ { location /api/ {
limit_except GET { deny all; } limit_except GET { deny all; }
proxy_pass http://mempool_backend; proxy_pass http://mempool_backend;
} }
location / { return 404; } # Share the already-installed explorer; no second frontend or index DB.
# Its SPA handles transaction/block deep links and static assets.
location / {
limit_except GET { deny all; }
proxy_pass http://mempool_frontend;
proxy_intercept_errors on;
error_page 500 502 503 504 =503 @waiting;
}
} }
} }
+10 -5
View File
@@ -1,22 +1,26 @@
app: app:
id: angor-indexer id: angor-indexer
name: Angor Indexer name: Angor Indexer
version: 1.0.1 version: 1.0.2
description: Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool description: Bitcoin indexer endpoint for Angor with the existing Mempool explorer.
Reuses this node’s Mempool
and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s
address as the custom indexer in Angor settings. A relay is optional and installed address as the custom indexer in Angor settings. A relay is optional and installed
separately. separately.
category: money category: money
install_prerequisites: install_prerequisites:
- mempool
- mempool-api - mempool-api
upstream: upstream:
kind: github kind: github
repo: block-core/angor repo: block-core/angor
container: container:
image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.1 image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.2
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
dependencies: dependencies:
- app_id: mempool
version: '>=3.0.0'
- app_id: mempool-api - app_id: mempool-api
version: '>=3.0.0' version: '>=3.0.0'
- bitcoin:archival - bitcoin:archival
@@ -40,8 +44,8 @@ app:
interfaces: interfaces:
main: main:
name: Angor Indexer API name: Angor Indexer API
description: Use this origin as Angor’s custom mainnet indexer URL. HTTPS is description: Use this origin as Angor’s custom mainnet indexer URL, or open it
required for browser clients. to view the existing Mempool explorer. HTTPS is required for browser clients.
type: api type: api
port: 8998 port: 8998
protocol: http protocol: http
@@ -63,6 +67,7 @@ app:
repo: https://github.com/block-core/angor repo: https://github.com/block-core/angor
features: features:
- Angor mainnet API - Angor mainnet API
- Mempool explorer on the same origin
- Reuses existing Mempool indexing - Reuses existing Mempool indexing
- No separate blockchain database - No separate blockchain database
- Optional independent relay - Optional independent relay
+12
View File
@@ -8,6 +8,18 @@ this is a public relay, not a private messaging archive. It mounts only
`/var/lib/archipelago/angor-relay` and its separate configuration directory. `/var/lib/archipelago/angor-relay` and its separate configuration directory.
It never opens, reconfigures or shares the node's internal strfry database. It never opens, reconfigures or shares the node's internal strfry database.
For a public domain, proxy HTTPS to node port **8091**, enable WebSocket upgrade,
and add `wss://your-relay-domain/` in Angor. Test both NIP-11 metadata (send
`Accept: application/nostr+json`) and a real Nostr subscription over WSS. An
Archipelago login page at this domain is a routing failure, not relay readiness.
New relays start without project history. Keep existing discovery relays alongside
yours until the needed original signed announcements and metadata are available
locally. Relays do not automatically synchronize. Any history import must retain
the original event IDs and signatures; verify funded projects against their
on-chain commitments. A working WebSocket with zero stored events is not proof
that the client's project discovery works. See the indexer README's browser test.
The configuration is seeded only when absent, preserving operator changes. The configuration is seeded only when absent, preserving operator changes.
Stop the service before making a consistent backup of its event database. Stop the service before making a consistent backup of its event database.
Ordinary start/restart/recreation preserves both mounts. Use the standard app Ordinary start/restart/recreation preserves both mounts. Use the standard app
+112
View File
@@ -0,0 +1,112 @@
# Angor client acceptance and remaining project recovery
Status: **OPEN — live services and one complete project flow pass; full project recovery is incomplete.**
The operator requires actual Angor-client verification and any necessary application
fixes to reach the next OTA, app catalog and ISO. Main release owner acknowledged
ownership of NPM/public management isolation; this investigation owns Angor evidence,
app setup documentation and the scoped browser test. Do not treat this report as
permission to waive the outstanding discovery/recovery requirements.
## Verified live behavior
- Trusted HTTPS indexer health, fees, address transactions and cursor pagination work.
- Real browser requests from `https://angor.io` reach the selected custom indexer;
checked transaction IDs, confirmation data and output scripts match the reference.
- The indexer serves all 35 funding transactions listed in the public reference
snapshot, confirmed, with matching original announcement commitments. This checks
that snapshot, not every possible Bitcoin address or the entire project universe.
- Relay NIP-11 and encrypted WebSocket/Nostr read work. An invalid signature was
rejected before importing the valid original announcement; the original signed
announcement was accepted unchanged and read back exactly.
- Imported ONLY the original public kind3030 event
`adbf94d6152097f3d503cd68cc00dee34c1e1007914c00cfc3d6698d1ee01834`, after checking its
Schnorr signature with nostr-tools and matching funding transaction
`72d14227b78a260c9410a65585cb49e81e35eaa95b1b23ec702eb0512ca016a7` on our indexer.
No fabricated, re-signed or modified announcement, wallet operation or payment.
- Restarted only the managed Angor relay. Its original announcement persisted;
node-internal strfry container ID and start time remained unchanged.
- Clean Chromium, configured with our indexer plus original relays and our relay,
now displays **Casa Bitcoin sv** in Explore and loads its full project page,
metadata, funding transaction and Project Statistics.
- `tests/lifecycle/angor-public-browser.cjs` passes all three acceptance groups:
trusted public API/WSS and chain commitment; actual Explore discovery;
actual project details/statistics. It is opt-in and read-only, uses a disposable
profile and known immutable public fixture, and does not disable TLS checks.
Live test command (run from repository root):
```sh
ANGOR_TEST_INDEXER=https://angor-indexer.tx1138.com/ \
ANGOR_TEST_RELAY=wss://angor-relay.tx1138.com/ \
ANGOR_TEST_RELAYS='["wss://relay.angor.io","wss://relay2.angor.io","wss://angor-relay.tx1138.com"]' \
node tests/lifecycle/angor-public-browser.cjs
```
## Empty project list: reproduced upstream behavior
The user retained the original relays. Our earlier suggestion that an empty new
relay explained the entire failure was incorrect.
Angor Hub v2.0.0 (`main-575CWKJX.js`) was tested in separate clean Chromium profiles
using our indexer and `https://indexer.angor.io/`. Both received candidate Nostr
announcements, but the current batch failed validation: `event-mismatch` for
updated announcements of one funded project, and `not-found-in-mempool` for another
address that both indexers correctly return empty. Both displayed zero projects.
The default primary `fulcrum.angor.online` separately failed browser CORS in a
fresh default-config test. These upstream failures must not be attributed to
missing data in our indexer without evidence.
Current `block-core/angor-hub` `src/app/services/indexer.service.ts` discovers
kind3030 events, filters by network, and calls `validateAndAddProjects` in the
background. When a batch has candidates but none validate, it does not continue
discovery to older valid original announcements. The Load More UI is unavailable
when the project list is empty. An empty relay can coexist with this bug but is
not its sole cause. After the authentic announcement was copied to our relay,
one original passed the unchanged validation and appeared. This is a bounded
history-availability recovery, NOT an upstream UI fix or global relay sync.
The current browser uses ordinary `/api/v1/address/.../txs`, not the deprecated
`/api/v1/query/Angor/projects` listing. Our adapter's 404 for the latter is NOT the
observed browser failure. Do not add an opaque upstream proxy to hide that 404.
## Other projects remain unresolved
The public reference query returned 35 project records (limit50). All35 on-chain
funding commitments were verified through our indexer. Exact original-event-ID
queries were sent to the configured relays `relay.angor.io`, `relay2.angor.io`,
`nos.lol`, `relay.primal.net`, and `relay.damus.io` (Damus had an initial connection
failure, then answered EOSE on retry). Only one original announcement was found.
Additional queries to `relay.snort.social`, `nostr.mom`, and `no.str.cr` returned
no matches; `relay.nostr.band` and `relay.f7z.io` were unavailable. This does not
prove the other34 events are globally lost or that all storage sources were checked.
Exact project IDs, transaction IDs, event IDs and confirmed commitment results:
[recovery inventory](angor-project-recovery-20261001.json). Recover originals from
founders/known archives or authoritative relay backups and retain their signatures.
On-chain commitments cannot reconstruct missing off-chain project content.
Do not fabricate replacements or accept mismatched events to make cards appear.
## Release handoff and open gates
- [x] Release owner acknowledges these final findings and the 34-project gap.
Confirmed 2026-10-01 18:46:50 UTC in `/tmp/angor-final-handoff-ack.txt`: full
recovery remains open; publication held for required gates; preserve relay
data and rerun browser acceptance after bridge migration and OTA.
- [ ] Resolve or explicitly carry the upstream discovery bug with accurate user
guidance; do not claim full recovery because the fixture passes.
- [ ] Locate and verify remaining original project metadata/history, or obtain an
explicit operator scope decision; one project is not complete acceptance.
- [ ] Preserve the verified public relay event/data through the candidate upgrade.
- [ ] Repeat scoped browser acceptance after NPM bridge migration and OTA install.
- [ ] Verify fresh ISO setup, NPM host propagation, public IP/default-host isolation,
certificate issuance/renewal and relay WebSocket routing; see NPM handoff.
- [ ] Include updated app setup documentation and the read-only acceptance test.
No Angor image/config change was justified by the verified transaction data;
no image or catalog version should be bumped solely to disguise upstream failure.
- [ ] Existing signed transaction-broadcast integration tests remain required;
this live investigation did not send real Bitcoin or test a funded investment.
Raw local diagnostic logs are in `/tmp/angor-*-browser*.log` and
`/tmp/angor-public-browser-acceptance.log`. The durable facts and recovery inventory
above must remain available after temporary logs expire.
+286
View File
@@ -0,0 +1,286 @@
{
"status": "INCOMPLETE: 34 original announcements not recovered from queried relays",
"source_inventory": "https://indexer.angor.io/api/v1/query/Angor/projects?limit=50",
"projects": [
{
"project": "angor1qryhse38vcyqnp0j6976q9f00a9jpj2ary03nlc",
"txid": "72d14227b78a260c9410a65585cb49e81e35eaa95b1b23ec702eb0512ca016a7",
"event_id": "adbf94d6152097f3d503cd68cc00dee34c1e1007914c00cfc3d6698d1ee01834",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": true
},
{
"project": "angor1q3eh4xg7t2hge7ctqk4yhmj7q23t6mdqa0ahg28",
"txid": "f5ff2e1a6b7340ddb9944c2ae6477c6b0b12993c9919f6e9b7b9e7a5e9a68f6d",
"event_id": "f4417e39c9b47afafd84cdda2017207e0929df2852badbf8d909bf6f647f4f2d",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qde7y830vtajref5vwag5x459m9w5y75gd49v4t",
"txid": "205ce39688572ef0168c1c1231c25ec632abb2c2b64c52fccb4f53eb0a49e300",
"event_id": "ca76084c2bc3301f8fe00dd8a93c6d6c0ad015e50676cdd3e44c8bd765f157d5",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1q9k9976ytwkkmswlq24e89l2fxuxl57gfp8yxgr",
"txid": "00a0120818698ef3d72d360e29e9e4e7d0a35c180967186d1e715aef797c26a8",
"event_id": "a04d1eed8c1261cb5fca6a6faf16a5f87a9672cf40d1117b25f2da60e6e8f84c",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1q67h8cktwy8yv32t0uk0c8zrtpmtwgtd56sylzx",
"txid": "1bfa726353a40d5fce1a76ad86dc7915bee214573d30d5751cd6312c94519089",
"event_id": "b3189f84b490da422e17b6b857d393b13781bb82e1c8813328769e3d2840098a",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qm999ekmrdjl4sq3qywl889w74qetxe3ghdxthl",
"txid": "ae9d471dd6e58b318120fbdf72929c621cf23d9c6f047df3c57923a8a915d01c",
"event_id": "89ae5e612e857f89dbbdc662198b894f1f7b70564769bedf08ec37f5e7b5efe0",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z",
"txid": "c2cb77ebf6b9ded802b677c600c3869b81fc76f31a3e4742b6a9cb16e0ac3dee",
"event_id": "f5e66707f8fa0fc601bef403020e64d9a164d6cf201599ec0c3ddb0acf58491e",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qu8h0ezv596z35uggg0r0ppkma93tnreyjg5du7",
"txid": "fb6bc0b721810957ae8910d5dd6e7ebc45d804a3e1f636d153df50f036e90645",
"event_id": "ce61a11b79aa258238db63f67472341169944bce02600045bf7278992aeb796c",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1q8u9c2h2l0eqjn3qeyvdps89dkkvwteg5q3m708",
"txid": "04ed05297e146206c104c7b5d8a51fc3453f1829950675b4694f91a7ea609be1",
"event_id": "f31c6ef3a66e74ebfc26f5b2905e6d4641f73bdb407aa92022a2202b9be54716",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qqnxefpr4f59r5f4u34c7crzst2ya83wavh8407",
"txid": "bae879110e78ad44624980ea4a47de21b03d3994139714bb09e910187027583d",
"event_id": "cbd23077098059c5092bb7ea8df14dd73f21d47d1b690ff1e6fa789ad118864b",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qfydf802v2efvxufevrfg0mvtlrxln63rkzmdxw",
"txid": "4bbb04fed973c968e76faaca880197092be288d9897072ab5e6dfb719c19eb69",
"event_id": "e9761e1303de7eca0ade33936489d2b4b7d51f4b52e8ecbc1acfc394df48b95d",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qpt96uj5jg77q5566pmcdggyuztt26xn3xymwhn",
"txid": "bc46fea91acf4714f5b7949e9bb937f39e425468242c2459b581a14914e641b4",
"event_id": "f683c12dbc3d574797bd2251d7e9d96e1d33d6263f75a1538d2ce5fd0c86e064",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1q3nleprspa3h6thy9c25wzu4q7ywajhcdg3j9au",
"txid": "7b76a8297f16ad5ff3d69fc85e37405ad4f77a71dfc8f70206a5a9c1827698ba",
"event_id": "893e5b7f09a12368f208120deb9c02b3692aee15240a914fb428e7e4f86b1123",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qc8jlugwgp90vzkhf336d8exldhwd8z5u4ssaen",
"txid": "5e06eb3684bf0d3402d20d643d6fe1b5a295680a29db440e070a89f80e52a241",
"event_id": "375eafd0e03c50d683de4f465501e919a8816ae618ca2b5c14e8d3f56daa90c0",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qdhwn7s0p8wcr9kw2932m448y6hd8ywl005fdwu",
"txid": "7cc5bff45f0b5e9ae81cadaf787dd4d4680387ab9fddedf8438eae8f87ef34ec",
"event_id": "6c7767eaf6ee0ae4ffe4b23c8477f2293216279e5908dd979abd4abdf1557578",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1q8qzylut0gv7urycxukdfumzw6znghrk4g928k2",
"txid": "2c7eed8d9b8eef530a4a8f39339f7dfaa82d5ec09261305203d1a367a624be1b",
"event_id": "24323aae4bca910ddb48544b788860119c3cc13ac19b24694221f1ba2521cd6b",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qzwrm6hf5l0tgen99anr9hj7ylk38v6zhmnq7w0",
"txid": "934c45244c283ff24834bab7d01a0964192433cd2bc11143fc5e590b2b954deb",
"event_id": "6cfae243c276a602ce2da33842ad930019d75e80ffe81b4cd84b1b187830bba7",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qpdp5d2ahknhtr7kawsl62m7y4hktsfn4uwg6r3",
"txid": "dc85d36b324ff829a4d49a606573e136fbc29498bc707d95f5bd0d9ef49956d0",
"event_id": "c6d22deef6d1babc99716746f50509e5413b97445d991a0869f0567b7301fc97",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1q9v88fe2q2efr4z57wed4gklmkh7trmx6usac0d",
"txid": "b98b23e49630f92ac0dedb7e0a8ac5ad2c51f813039f2d9dd708c9a0861b2ca2",
"event_id": "e8f518eaed658e9331b3a3feba49f36eda5eb7fe68c81c7b3f71057844729bcb",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1q0gtztyk24ea2028ewfn026838k3ll322qrvrhu",
"txid": "31b38cf48cf18936b7c370ee72e8bda6e9ee40f24ec676a85b9b8b1abefebdd4",
"event_id": "fe34db328e51cc10f9c4d035b0f0aab7f9137a4cdee3a2afc4c6610404e79fe4",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1q65fuwpyvek3fxk5p757zyknjd9k9sava3fd98j",
"txid": "0b7bf7b9119157edc778bcdc7088b70a439fa5dc8c46e839b95fd2f0a8fbf046",
"event_id": "991d0f7d1c261e4d79507238e3d5e4b3d3adce267cf40107ee2ade32485f5cf6",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qjqfp23ac4s4llgcgs3qnenjhpasgrmvt373usp",
"txid": "8b4887aba04b12729e609658b0fdbb1e6f1b172ba10360c7a5261c3e9bd590f9",
"event_id": "254910567be531d7862afcbcc80b490ee92a5f9ed801424f3d56108d9a114b80",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qfyzk83tqznc3aqg5ly0cewn7ytsrwl474v4dyn",
"txid": "3e2ea870b17eab2023a04dca267c45d2c53a41abb3f20b206e095fcbba6c5f02",
"event_id": "1e27aa63f276048ba78ef73f69dc5045441feafaa090aff9b995341883f22fb4",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qw7m67a5r6vpmtw2jqpsp4xtxvtu6mmnkgj6gxw",
"txid": "f3d3549b2a30c78e7c3b51bc9122e0ae8a7ecb378f9b3564ebf931769637df49",
"event_id": "fc4289a4888bfe157588e507204d93723a8c07074cde37bb98cf866793f81c98",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qgdc07hkk0l4kntg2k5fczk7tcx0qeqqx2saru0",
"txid": "c1332c51da2c5706f6fc74e3275856438304a7761ced4dcd2e738a7e3c62bd2f",
"event_id": "ae1d1f7f883907dea36902fc2dc78c8ad81d22bd59bd7293ba1725c6cc959846",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qhklyl9mw5zwzwynxv6ekaz5f9h5zv3wnd8dn2c",
"txid": "be637cd8a23c80f49195345eca5d4c29bf4a9fa0600e0625af702444e300d218",
"event_id": "78a1443b14cb252b2510925263889e8c8f40b12fd911d137d63b1dbc2b841a3a",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1q4heck5tf6svq7x8jp5twak329xtv9805xppvq5",
"txid": "509db524eba0b90e8607396e9eb42ed379a270f3aa602a0bc16eec050c959823",
"event_id": "dec392d7d962e7dfd2c9eaa2b05dfc03c909d87982c4d73b9f321dc13487622f",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qc6gykcw82hu3pa4pn94gfxwgpp8dewlsh45rwc",
"txid": "087390128a78270cd1465656e3ab63b9b47982dca1a910b7a5664f67f8ab9970",
"event_id": "cf634987daa4ee17bb2d160ddb04ce56a0d73e664b9822e5e9a8edf870630a9c",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qfzxd8n94r592gt3mrmgwe7knk6dhajmutpuhkx",
"txid": "29cb361fe78b6f199f169b7b4a7d9663b7e7f46607f382ddcf8cb2224a6023f1",
"event_id": "ca7a6e0bb27beb46fb8e709378908feb5357e7edd43a9262318cadf72da5f141",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1q0whhl3qgq28g443h9mj6dl8n2ssshm6hkd3xse",
"txid": "4d70fba03ec51d87df7df16498a95a907b9cff00035455e3ab8242935f260030",
"event_id": "3a19b34d76ec7b99eb98ed299737c06cd3710268febf7d2acf6ce1fece9ba459",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1q2a5m2zcwpmkh49z05pg6gd9cxm4dhx3ywfclem",
"txid": "5da06a119db273bbb7c64e2cc103a8edb48a9934ab9b5b972ae55f697023ce07",
"event_id": "8c3a8dcaf9c55e7797cf4ff9a25b0e7cd7dcbe78c8ed248d53a49c70f9ea4a6b",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qq9ngpm2w8xss5sf0amt63z076y2x885wh0jfv7",
"txid": "7f42da75e4b3dd92f9870aefa177c2fb3783f189996abf193b3b353ee21e805e",
"event_id": "2f0c6e3b29a74be45033062742b3fbac4a4c7b7be4eeecc7021df4a8b1b195cf",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qwdgxjuzhjykgpn5q8p3l2q9vyrgqdlrkfp5sjr",
"txid": "c15d07fd14d58e7204889ba24fe47a9b86aa7bea9992d30f4be36864e7634725",
"event_id": "733b28b35f771839bc719125af94916a5400675185d4a75edb09645c8eb4cc24",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qyuj8z532tnhy7srutwecu3j789z22peu2t8c7v",
"txid": "821193743f7ca7b0b178a78379d79f5783d874a182a8bf36b70a0a2a6cb12d24",
"event_id": "56fce837c628728953138ca57895c7ef9533640a989934c432d1040808781b9f",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
},
{
"project": "angor1qznva9wmw3anr7qdhhs4v0xptd0pz07gdr2fuwz",
"txid": "187e39a0ba9714ae3543bbe775dd9fff9c7a4ac946ce0a850480c3871de287fe",
"event_id": "baff907b6f1fb97893e63e1bef6919f819c1b6e37560ed2d4255b77cc26d08cd",
"confirmed": true,
"commitment_matches": true,
"original_announcement_recovered": false
}
]
}
+34 -7
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Opt-in disposable rootless Angor gateway integration checks. No native app changes.""" """Opt-in disposable rootless Angor gateway integration checks. No native app changes."""
import subprocess,pathlib,json,urllib.request,urllib.error,time,tempfile,os,uuid,shlex import subprocess,pathlib,json,urllib.request,urllib.error,time,tempfile,os,uuid,shlex,socket
import yaml import yaml
if os.environ.get('ARCHY_ALLOW_DISPOSABLE_CONTAINERS') != '1': if os.environ.get('ARCHY_ALLOW_DISPOSABLE_CONTAINERS') != '1':
raise SystemExit('Set ARCHY_ALLOW_DISPOSABLE_CONTAINERS=1 to run isolated test containers') raise SystemExit('Set ARCHY_ALLOW_DISPOSABLE_CONTAINERS=1 to run isolated test containers')
@@ -8,17 +8,20 @@ manifest=yaml.safe_load((pathlib.Path(__file__).resolve().parents[2]/'apps/angor
health=manifest['health_check'] health=manifest['health_check']
health_url=health['endpoint'].rstrip('/')+health.get('path','/') health_url=health['endpoint'].rstrip('/')+health.get('path','/')
run_id=uuid.uuid4().hex[:12] run_id=uuid.uuid4().hex[:12]
net='archy-angor-test-'+run_id;backend='angor-test-backend-'+run_id;gateway='angor-test-gateway-'+run_id net='archy-angor-test-'+run_id;backend='angor-test-backend-'+run_id;gateway='angor-test-gateway-'+run_id;frontend='angor-test-frontend-'+run_id
port=None
def run(*a): def run(*a):
r=subprocess.run(a,capture_output=True,text=True) r=subprocess.run(a,capture_output=True,text=True)
if r.returncode:raise RuntimeError(r.stderr) if r.returncode:raise RuntimeError(r.stderr)
return r.stdout.strip() return r.stdout.strip()
def req(path,data=None,method=None,headers={}): def req(path,data=None,method=None,headers={}):
r=urllib.request.Request('http://127.0.0.1:19098'+path,data=data,method=method,headers=headers) r=urllib.request.Request(f'http://127.0.0.1:{port}'+path,data=data,method=method,headers=headers)
try: try:
with urllib.request.urlopen(r,timeout=10) as f:return f.status,f.headers,f.read() with urllib.request.urlopen(r,timeout=10) as f:return f.status,f.headers,f.read()
except urllib.error.HTTPError as e:return e.code,e.headers,e.read() except urllib.error.HTTPError as e:return e.code,e.headers,e.read()
script="""require('http').createServer((q,r)=>{let b='';q.on('data',x=>b+=x);q.on('end',()=>{r.setHeader('Access-Control-Allow-Origin','https://wrong.example');if(q.url==='/api/v1/blocks/tip/height'){r.end('900000');return}r.setHeader('Content-Type','application/json');r.end(JSON.stringify({url:q.url,method:q.method,body:b,cookie:q.headers.cookie||null,auth:q.headers.authorization||null}))})}).listen(8999,'0.0.0.0')""" script="""const server=require('http').createServer((q,r)=>{let b='';q.on('data',x=>b+=x);q.on('end',()=>{r.setHeader('Access-Control-Allow-Origin','https://wrong.example');if(q.url==='/api/v1/blocks/tip/height'){r.end('900000');return}r.setHeader('Content-Type','application/json');r.end(JSON.stringify({url:q.url,method:q.method,body:b,cookie:q.headers.cookie||null,auth:q.headers.authorization||null}))})});server.on('upgrade',(q,s)=>{if(q.url!=='/api/v1/ws'||q.headers.cookie||q.headers.authorization){s.destroy();return}const accept=require('crypto').createHash('sha1').update(q.headers['sec-websocket-key']+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');s.end('HTTP/1.1 101 Switching Protocols\\r\\nUpgrade: websocket\\r\\nConnection: Upgrade\\r\\nSec-WebSocket-Accept: '+accept+'\\r\\n\\r\\n'+String.fromCharCode(129,11)+'fixture-tip','latin1')});server.listen(8999,'0.0.0.0')"""
frontend_script="""require('http').createServer((q,r)=>{if(q.headers.cookie||q.headers.authorization){r.writeHead(500);r.end('credential leak');return}if(q.url==='/asset.js'){r.setHeader('Content-Type','application/javascript');r.end('window.explorer=true');return}if(q.url==='/'||q.url==='/tx/fixture'){r.setHeader('Content-Type','text/html');r.end('<html>Mempool explorer fixture</html>');return}r.writeHead(404);r.end('not found')}).listen(8080,'0.0.0.0')"""
def start_frontend():run('podman','run','-d','--name',frontend,'--network',net,'--network-alias','mempool','--cap-drop=all','--security-opt=no-new-privileges','docker.io/library/node:24-alpine','node','-e',frontend_script)
def start_backend():run('podman','run','-d','--name',backend,'--network',net,'--network-alias','mempool-api','--cap-drop=all','--security-opt=no-new-privileges','docker.io/library/node:24-alpine','node','-e',script) def start_backend():run('podman','run','-d','--name',backend,'--network',net,'--network-alias','mempool-api','--cap-drop=all','--security-opt=no-new-privileges','docker.io/library/node:24-alpine','node','-e',script)
def ready(seconds=40): def ready(seconds=40):
end=time.monotonic()+seconds end=time.monotonic()+seconds
@@ -31,8 +34,9 @@ def ready(seconds=40):
assert subprocess.run(['podman','network','exists',net]).returncode==1 assert subprocess.run(['podman','network','exists',net]).returncode==1
run('podman','network','create',net) run('podman','network','create',net)
try: try:
start_backend() start_backend();start_frontend()
run('podman','run','-d','--name',gateway,'--network',net,'--read-only','--cap-drop=all','--security-opt=no-new-privileges','--memory','128m','--health-cmd','wget -q -T 5 -O /dev/null '+shlex.quote(health_url),'--health-interval','5s','--health-retries','2','-p','127.0.0.1:19098:8080','source.archipelago-foundation.org/chaum/angor-indexer:1.0.1') run('podman','run','-d','--name',gateway,'--network',net,'--read-only','--cap-drop=all','--security-opt=no-new-privileges','--memory','128m','--health-cmd','wget -q -T 5 -O /dev/null '+shlex.quote(health_url),'--health-interval','5s','--health-retries','2','-p','127.0.0.1::8080',manifest['container']['image'])
port=int(json.loads(run('podman','inspect',gateway))[0]['NetworkSettings']['Ports']['8080/tcp'][0]['HostPort'])
ready() ready()
run('podman','healthcheck','run',gateway) run('podman','healthcheck','run',gateway)
assert json.loads(run('podman','inspect',gateway))[0]['State']['Health']['Status']=='healthy' assert json.loads(run('podman','inspect',gateway))[0]['State']['Health']['Status']=='healthy'
@@ -48,6 +52,29 @@ try:
assert req('/api/v1/tx',method='OPTIONS')[0]==204 assert req('/api/v1/tx',method='OPTIONS')[0]==204
assert req('/api/v1/tx',b'x'*(4*1024*1024+1))[0]==413 assert req('/api/v1/tx',b'x'*(4*1024*1024+1))[0]==413
assert req('/unknown')[0]==404 assert req('/unknown')[0]==404
for path in ['/', '/tx/fixture', '/asset.js']:
status,headers,body=req(path,headers={'Cookie':'private=secret','Authorization':'Bearer secret'})
assert status==200 and (b'explorer' in body), (path,status,body)
assert req('/',b'not-allowed')[0]==403
with socket.create_connection(('127.0.0.1',port),timeout=10) as stream:
stream.sendall(b'GET /api/v1/ws HTTP/1.1\r\nHost: indexer.example\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nCookie: private=secret\r\nAuthorization: Bearer secret\r\n\r\n')
response=b''
while b'fixture-tip' not in response:
chunk=stream.recv(4096)
if not chunk:break
response+=chunk
assert b'101 Switching Protocols' in response and b'fixture-tip' in response,response
print('PASS shared explorer root/assets/deep links and real WebSocket upgrade/frame; credentials stripped',flush=True)
run('podman','stop',frontend)
assert req('/')[0]==503
assert req('/health')[0]==200
run('podman','rm',frontend);start_frontend()
end=time.monotonic()+40
while time.monotonic()<end:
if req('/')[0]==200:break
time.sleep(1)
else:raise RuntimeError('Frontend recreation did not recover')
print('PASS frontend outage is explicit; API stays available; frontend DNS recreation recovers',flush=True)
d=json.loads(run('podman','inspect',gateway))[0];assert d['Config']['User']=='101:101' and not d['BoundingCaps'] d=json.loads(run('podman','inspect',gateway))[0];assert d['Config']['User']=='101:101' and not d['BoundingCaps']
print('PASS API paths/query/body, transaction-only POST, method/size limits, CORS, credential stripping and unprivileged read-only image',flush=True) print('PASS API paths/query/body, transaction-only POST, method/size limits, CORS, credential stripping and unprivileged read-only image',flush=True)
run('podman','stop',backend) run('podman','stop',backend)
@@ -58,5 +85,5 @@ except BaseException:
subprocess.run(['podman','logs','--tail','15',gateway],check=False) subprocess.run(['podman','logs','--tail','15',gateway],check=False)
raise raise
finally: finally:
for name in [gateway,backend]:subprocess.run(['podman','rm','-f','--time','3',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) for name in [gateway,backend,frontend]:subprocess.run(['podman','rm','-f','--time','3',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
subprocess.run(['podman','network','rm',net],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) subprocess.run(['podman','network','rm',net],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
/* Opt-in, read-only public acceptance. Uses a disposable browser profile; no wallet
* actions, transaction broadcast, relay publication, or browser TLS bypass. */
const assert = require('node:assert/strict');
const { createHash } = require('node:crypto');
const path = require('node:path');
const { createRequire } = require('node:module');
const requireUi = createRequire(path.resolve(__dirname, '../../neode-ui/package.json'));
const { chromium } = requireUi('playwright');
const WebSocket = requireUi('ws');
const indexer = process.env.ANGOR_TEST_INDEXER;
const relay = process.env.ANGOR_TEST_RELAY;
const projectId = 'angor1qryhse38vcyqnp0j6976q9f00a9jpj2ary03nlc';
const address = 'bc1qryhse38vcyqnp0j6976q9f00a9jpj2ar62vfzd';
const fundingTx = '72d14227b78a260c9410a65585cb49e81e35eaa95b1b23ec702eb0512ca016a7';
const eventId = 'adbf94d6152097f3d503cd68cc00dee34c1e1007914c00cfc3d6698d1ee01834';
const relays = JSON.parse(process.env.ANGOR_TEST_RELAYS || JSON.stringify([relay]));
const app = process.env.ANGOR_TEST_APP || 'https://angor.io';
assert(indexer && new URL(indexer).protocol === 'https:', 'Set ANGOR_TEST_INDEXER to an HTTPS origin');
assert(relay && new URL(relay).protocol === 'wss:', 'Set ANGOR_TEST_RELAY to a WSS origin');
assert(relays.some(url => new URL(url).href === new URL(relay).href),
'Discovery relays must include the relay under test');
async function read(endpoint, json = true) {
const response = await fetch(new URL(endpoint, indexer), { signal: AbortSignal.timeout(15000) });
assert.equal(response.status, 200, `${endpoint}: ${response.status}`);
return json ? response.json() : response.text();
}
function relayEvent() {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relay, { handshakeTimeout: 15000, origin: new URL(app).origin });
const timer = setTimeout(() => finish(Error('Relay project event timeout')), 15000);
function finish(err, value) {
clearTimeout(timer);
ws.removeAllListeners();
ws.on('error', () => {});
ws.close();
err ? reject(err) : resolve(value);
}
ws.on('error', err => finish(err));
ws.on('open', () => ws.send(JSON.stringify(['REQ', 'acceptance', { ids: [eventId] }])));
ws.on('message', raw => {
try {
const m = JSON.parse(raw.toString());
if (m[0] === 'EVENT' && m[1] === 'acceptance') finish(null, m[2]);
if (m[0] === 'EOSE') finish(Error('Relay lacks the known signed project fixture; provision public history first'));
if (m[0] === 'CLOSED') finish(Error(`Relay subscription rejected: ${m[2]}`));
} catch (err) { finish(err); }
});
});
}
(async () => {
assert.match((await read('/health', false)).trim(), /^\d+$/);
assert(Number.isFinite((await read('/api/v1/fees/recommended')).fastestFee));
const txs = await read(`/api/v1/address/${address}/txs`);
const tx = txs.find(t => t.txid === fundingTx);
assert(tx?.status.confirmed, 'Known project funding transaction missing');
const event = await relayEvent();
assert.equal(event.id, eventId);
assert.equal(event.kind, 3030);
assert.equal(createHash('sha256').update(JSON.stringify([0, event.pubkey, event.created_at,
event.kind, event.tags, event.content])).digest('hex'), eventId);
assert(tx.vout.some(v => v.scriptpubkey_type === 'op_return' && v.scriptpubkey.includes(eventId)),
'Project announcement must match the immutable funding commitment');
assert.equal(JSON.parse(event.content).projectIdentifier, projectId);
// This checks a known immutable fixture's hash/chain binding, not a general
// Schnorr verifier. Signed publish and invalid-signature rejection are separate gates.
console.log('PASS public TLS, indexed funding data, relay WSS, immutable project/event commitment');
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const failures = [];
let browserIndexerRead = false;
page.on('requestfailed', r => {
if (r.url().startsWith(new URL(indexer).origin)) failures.push(`${r.url()}: ${r.failure()?.errorText}`);
});
page.on('response', r => {
if (r.url().startsWith(new URL(indexer).origin) && r.url().includes(`/address/${address}/txs`) && r.status() === 200)
browserIndexerRead = true;
});
await page.addInitScript(({ indexer, relays }) => {
localStorage.setItem('angor-network', 'main');
localStorage.setItem('angor-indexers', JSON.stringify({ mainnet: [{ url: indexer.replace(/\/?$/, '/'), isPrimary: true }], testnet: [] }));
localStorage.setItem('angor-hub-relays', JSON.stringify(relays));
}, { indexer, relays });
await page.goto(new URL('/explore', app).href, { waitUntil: 'domcontentloaded' });
const link = page.locator(`a[href*="/project/${projectId}"]`).first();
await link.waitFor({ state: 'visible', timeout: 60000 });
assert(browserIndexerRead, 'Browser did not validate the project using the configured indexer');
assert.deepEqual(failures, [], 'Browser indexer/CORS requests failed');
console.log('PASS official Explore page discovers the known project with the configured services');
await page.goto(new URL(`/project/${projectId}`, app).href, { waitUntil: 'domcontentloaded' });
await page.getByText(projectId, { exact: true }).first().waitFor({ state: 'visible', timeout: 60000 });
await page.getByText('Project Statistics', { exact: true }).waitFor({ state: 'visible', timeout: 30000 });
assert.deepEqual(failures, [], 'Browser detail/indexer requests failed');
console.log('PASS official project detail and statistics render; no funds sent');
} finally {
await browser.close();
}
})().catch(err => { console.error(err); process.exitCode = 1; });
@@ -19,6 +19,13 @@ class ServiceMetadata(unittest.TestCase):
self.assertEqual(app['ports'][0]['bind'], '127.0.0.1') self.assertEqual(app['ports'][0]['bind'], '127.0.0.1')
self.assertEqual(app['security']['capabilities'], []) self.assertEqual(app['security']['capabilities'], [])
def test_indexer_reuses_both_existing_mempool_components(self):
app = yaml.safe_load((ROOT / 'apps/angor-indexer/manifest.yml').read_text())['app']
self.assertEqual(set(app['install_prerequisites']), {'mempool', 'mempool-api'})
self.assertTrue({'mempool', 'mempool-api'} <= {
dep['app_id'] for dep in app['dependencies'] if 'app_id' in dep})
self.assertEqual(app.get('volumes', []), [])
def test_indexer_health_targets_ipv4_listener(self): def test_indexer_health_targets_ipv4_listener(self):
app = yaml.safe_load((ROOT / 'apps/angor-indexer/manifest.yml').read_text())['app'] app = yaml.safe_load((ROOT / 'apps/angor-indexer/manifest.yml').read_text())['app']
self.assertEqual(app['health_check']['endpoint'], 'http://127.0.0.1:8080') self.assertEqual(app['health_check']['endpoint'], 'http://127.0.0.1:8080')