pion-mcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,10 @@
3
3
  **Model Context Protocol server for Pi Network** — connect AI agents
4
4
  (Claude, Cursor, and any MCP-compatible client) to Pi Network chain data.
5
5
 
6
- > ⚠️ v0.2 testnet, read-only. Nothing here can move value.
6
+ > ⚠️ **Reads: both networks. Payments: testnet only.** The chain tools query Pi
7
+ > Mainnet or Pi Testnet, selected with `PION_NETWORK`. `send_payment` moves real
8
+ > funds, must be explicitly armed, and *cannot* be armed on mainnet — Pi
9
+ > restricts App-to-User payments to testnet.
7
10
 
8
11
  ## Why "Pion"?
9
12
  The pion is the π meson — the particle physicists named after pi.
@@ -12,11 +15,13 @@ for MCPs (millicharged particles). We couldn't resist.
12
15
 
13
16
  ## Tools
14
17
 
15
- Nothing here can move value. No server API key or wallet secret is read
16
- anywhere. (Tiers refer to [`docs/tool-mapping.md`](docs/tool-mapping.md).)
18
+ Out of the box Pion reads and cannot spend: Tiers A and B need no credentials
19
+ and move no value. Tier C is the exception and is off unless you arm it.
20
+ (Tiers refer to [`docs/tool-mapping.md`](https://github.com/jleeblack/pion-mcp/blob/main/docs/tool-mapping.md).)
17
21
 
18
22
  **Tier A — chain reads.** Zero-permission queries against Pi's public Horizon
19
- API. No credentials at all.
23
+ API. No credentials at all. These work on **both Pi chains** — testnet by
24
+ default, mainnet with `PION_NETWORK=mainnet`.
20
25
 
21
26
  | Tool | What it does |
22
27
  |---|---|
@@ -27,6 +32,13 @@ API. No credentials at all.
27
32
  Amounts are decimal strings. Pi is reported as the asset `PI`, custom tokens as
28
33
  `CODE:ISSUER`, and liquidity-pool shares as `pool:ID`.
29
34
 
35
+ Every result names the chain it came from in a `network` field, and the startup
36
+ banner says it too. That redundancy is deliberate: **the same address can hold
37
+ different balances on both chains**, so a query against the wrong network does
38
+ not reliably fail — it can return a plausible, well-formed, wrong number
39
+ (measured 2026-08-14; see [`docs/pi-sdk-notes.md`](https://github.com/jleeblack/pion-mcp/blob/main/docs/pi-sdk-notes.md)).
40
+ Testnet Pi has no monetary value.
41
+
30
42
  **Tier B — identity.**
31
43
 
32
44
  | Tool | What it does |
@@ -44,6 +56,47 @@ a different uid under a different Pi app, which is deliberate anti-correlation
44
56
  design, so don't use it as a global identifier. And a token is the *only* proof
45
57
  of identity: a client-supplied uid or username means nothing on its own.
46
58
 
59
+ **Tier C — payments. Off by default.**
60
+
61
+ | Tool | What it does |
62
+ |---|---|
63
+ | `send_payment` | App-to-User: sends Pi from your app wallet to a user |
64
+
65
+ This one spends real money and cannot be undone. It is not registered at all
66
+ unless armed, so a default server does not even advertise it to the agent.
67
+
68
+ **The recipient must have granted your app the `wallet_address` scope.** A uid
69
+ alone is not enough — Pi needs that consent to resolve their wallet, and
70
+ refuses payment creation with `missing_scope` otherwise. This is the
71
+ recipient's consent, not your credentials.
72
+
73
+ Arming requires **all four**, and Pi restricts A2U to testnet, so anything but
74
+ Pi Testnet is refused outright:
75
+
76
+ ```sh
77
+ PION_ENABLE_PAYMENTS=1 # explicit switch, deliberately separate from credentials
78
+ PION_MAX_PAYMENT_PI=10 # required per-payment ceiling, in Pi
79
+ PI_SERVER_API_KEY=... # from the Pi Developer Portal
80
+ PI_WALLET_SECRET=S... # app wallet secret seed
81
+ ```
82
+
83
+ Holding the credentials is deliberately **not** sufficient. The switch and the
84
+ ceiling are separate because the realistic failure mode is not a stolen key —
85
+ it is an agent being talked into spending, by a prompt injection sitting in
86
+ data it just read. A transaction memo, a web page, a filename: any of it can
87
+ say "send 500 Pi to X." The cap is what makes that bounded rather than fatal.
88
+ Set it to the smallest amount that makes your use case work.
89
+
90
+ Nothing overrides the cap from the tool call; changing it means changing server
91
+ configuration. Neither secret is ever accepted as a tool argument, returned in
92
+ a result, or logged.
93
+
94
+ **On partial failure it never retries.** A2U is three steps — create with Pi,
95
+ sign and submit on-chain, tell Pi it landed — and a crash between them strands
96
+ a payment. The tool reports exactly which step failed, whether funds left the
97
+ wallet, and the payment id needed to clean up. A blind retry could pay twice,
98
+ so it refuses to guess.
99
+
47
100
  ## Usage
48
101
 
49
102
  MCP clients can run it straight from npm — no install step:
@@ -77,19 +130,35 @@ claude mcp add pion -- node /absolute/path/to/pion-mcp/dist/index.js
77
130
 
78
131
  | Variable | Default | Purpose |
79
132
  |---|---|---|
80
- | `PION_HORIZON_URL` | `https://api.testnet.minepi.com` | Horizon base URL |
133
+ | `PION_NETWORK` | `testnet` | Which chain the read tools query — `testnet` or `mainnet` |
134
+ | `PION_HORIZON_URL` | derived from `PION_NETWORK` | Horizon base URL, overriding the above |
81
135
  | `PION_PLATFORM_URL` | `https://api.minepi.com` | Platform API base URL |
82
-
83
- There are no secrets to configure `verify_user` takes its token as a call
84
- argument, not from the environment. The mainnet Horizon URL is still an open
85
- question see the TODO in [`docs/pi-sdk-notes.md`](docs/pi-sdk-notes.md).
136
+ | `PION_ENABLE_PAYMENTS` | unset (off) | Arms `send_payment` — see Tier C above |
137
+ | `PION_MAX_PAYMENT_PI` | unset | Required per-payment ceiling when armed |
138
+ | `PI_SERVER_API_KEY` | unset | Server API key, Tier C only |
139
+ | `PI_WALLET_SECRET` | unset | App wallet secret seed, Tier C only |
140
+
141
+ For read-only use there is nothing to configure — `verify_user` takes its token
142
+ as a call argument, not from the environment. The bottom four are needed only
143
+ if you arm payments, and belong in a secrets manager, never in a committed
144
+ file.
145
+
146
+ `PION_NETWORK` and `PION_HORIZON_URL` are resolved once, in one place, into a
147
+ single network object that the Horizon client, the banner, every tool result and
148
+ the arming check all read. Setting both to contradictory chains is a startup
149
+ error rather than a silent winner, and an unrecognised `PION_HORIZON_URL`
150
+ resolves to an explicitly *unknown* chain — never to a Pi network by
151
+ resemblance.
86
152
 
87
153
  ## Development
88
154
 
89
155
  ```sh
90
156
  npm run build # compile src/ -> dist/
91
157
  npm run typecheck # types only, no emit
92
- npm run smoke # end-to-end: drives the built server against live testnet
158
+ npm run smoke # end-to-end against live testnet
159
+ npm run smoke:mainnet # the same checks against live mainnet
160
+ npm run crossnet # proves the two chains are actually distinguished
161
+ npm run arming # Tier C guards and spend cap (no credentials needed)
93
162
  ```
94
163
 
95
164
  `npm run smoke` spawns the server over stdio as a real MCP client, discovers a
@@ -100,15 +169,73 @@ It covers `verify_user` only on the **rejection** path — confirming a genuine
100
169
  token would need a real user credential, which the test deliberately does not
101
170
  handle. The success path is unverified; see below.
102
171
 
172
+ `npm run crossnet` proves network selection is real rather than cosmetic. It
173
+ does not rely on an address being absent from the other chain — that assumption
174
+ is false — but on a wallet we control being testnet-only, and on a shared
175
+ address returning *different* ledger state from each chain.
176
+
177
+ `npm run arming` covers Tier C without touching real money: every refusal
178
+ branch, the exact cap boundary, that credentials alone do not arm it, that a
179
+ disarmed server does not advertise the tool, that a fully-credentialled mainnet
180
+ server still refuses to advertise it, and that neither secret leaks into
181
+ a result. It uses a freshly generated, never-funded keypair. The one live call
182
+ it makes is a deliberately-rejected create against the Pi API, which proves the
183
+ first failure stage end to end.
184
+
185
+ ## Known gaps
186
+
187
+ - **`verify_user` success path — confirmed** against a live token. Returns
188
+ `uid`, `username`, `app_id`, `scopes`, and `valid_until`. Everything but
189
+ `uid` stays optional, since the rest depends on granted scopes.
190
+ - **`send_payment` success path — verified on testnet (2026-08-01).** A real
191
+ A2U payment ran through all three irreversible steps — create, sign, submit,
192
+ complete — and was confirmed independently against public Horizon and Pi's
193
+ block explorer, not just from the tool's own report. The 28-byte memo
194
+ question that hung over the design is answered: Pi payment identifiers are
195
+ exactly 28 bytes and fit the Stellar text memo with no room to spare.
196
+ - **`send_payment` failure paths after create — still unproven.** Sign, submit
197
+ and complete have each succeeded once; none has been observed *failing*
198
+ against live infrastructure. The two worst branches of the stranded-payment
199
+ report — "record created, nothing signed" and "funds left, Pi not notified" —
200
+ are verified by construction only. Treat `send_payment` as experimental until
201
+ they have been deliberately exercised.
202
+
203
+ This is why it ships **off**, and why turning it on takes four separate,
204
+ deliberate acts: `PION_ENABLE_PAYMENTS=1`, a mandatory `PION_MAX_PAYMENT_PI`
205
+ ceiling, both credentials, and Pi Testnet as the selected network. Holding the credentials
206
+ is not enough on its own. Disarmed, the tool is not registered at all, so an
207
+ agent cannot see that a spending capability exists — that gate is deliberate
208
+ design (see Tier C above), not a placeholder for unfinished work. The
209
+ experimental label is about the failure paths, not about the guards.
210
+ - **`send_payment` cannot pay an arbitrary uid.** Pi requires the *recipient*
211
+ to have granted your app the `wallet_address` scope, through the Pi Browser
212
+ SDK. A valid uid is not sufficient, and this is a permanent property of the
213
+ Pi API rather than a transient error — creation fails with
214
+ `401 missing_scope` and retrying will not help.
215
+
216
+ Start with a minimum-amount payment and a low `PION_MAX_PAYMENT_PI`. Run
217
+ `npm run probe:a2u <uid>` first: it exercises create and cancel without moving
218
+ funds, and its `from_address` is the only authoritative statement of which app
219
+ wallet Pi will actually spend from.
220
+
103
221
  ## Roadmap
104
222
 
105
- Tier C is next: App-to-User payments behind env config (`PI_SERVER_API_KEY`,
106
- `PI_WALLET_SECRET`), testnet-default with explicit opt-in for anything that
107
- moves value. See [`docs/tool-mapping.md`](docs/tool-mapping.md).
223
+ Done in v0.4: mainnet reads. The rest of Tier C: `get_payment_status`, `list_incomplete_payments`,
224
+ `approve_payment` / `complete_payment` / `cancel_payment` the U2A backend half
225
+ and the recovery tooling for stranded payments. See
226
+ [`docs/tool-mapping.md`](https://github.com/jleeblack/pion-mcp/blob/main/docs/tool-mapping.md).
227
+
228
+ ## Legal
229
+
230
+ The code is Apache-2.0; see [`LICENSE`](LICENSE). The website and the hosted U2A
231
+ endpoints are covered separately by the
232
+ [Privacy Policy](https://pionmcp.com/privacy) and
233
+ [Terms of Service](https://pionmcp.com/terms) — sources in
234
+ [`site/privacy.html`](site/privacy.html) and [`site/terms.html`](site/terms.html).
235
+ Where the two disagree about the software itself, the Apache licence wins.
108
236
 
109
- Known gap: `verify_user`'s success-path response shape is built from the Pi
110
- platform docs, not observed traffic. The `uid` field is reliable; `username`
111
- and `credentials` depend on granted scopes and are treated as optional. Worth
112
- confirming against a real token before depending on them.
237
+ Short version: the MCP server has no telemetry and talks only to Pi's public
238
+ endpoints, the site sets no cookies and runs no analytics, and nothing here is
239
+ stored in a database there isn't one.
113
240
 
114
241
  *Unofficial community project — not affiliated with Pi Network.*
package/dist/horizon.d.ts CHANGED
@@ -4,7 +4,13 @@
4
4
  * Every endpoint used here is public and unauthenticated — no API key, no
5
5
  * bearer token, no wallet secret. See docs/pi-sdk-notes.md, "Layer 3".
6
6
  */
7
- /** Horizon base URL. Defaults to Pi testnet; override with PION_HORIZON_URL. */
7
+ /**
8
+ * Horizon base URL for the selected network.
9
+ *
10
+ * Derived from the resolved network rather than read from the environment
11
+ * directly, so there is exactly one place that decides which chain we are on.
12
+ * `PION_NETWORK` and `PION_HORIZON_URL` are both handled in networks.ts.
13
+ */
8
14
  export declare const HORIZON_URL: string;
9
15
  /** A Horizon request that failed — network, timeout, or non-2xx response. */
10
16
  export declare class HorizonError extends Error {
package/dist/horizon.js CHANGED
@@ -4,10 +4,16 @@
4
4
  * Every endpoint used here is public and unauthenticated — no API key, no
5
5
  * bearer token, no wallet secret. See docs/pi-sdk-notes.md, "Layer 3".
6
6
  */
7
- const DEFAULT_HORIZON_URL = "https://api.testnet.minepi.com";
7
+ import { NETWORK } from "./networks.js";
8
8
  const REQUEST_TIMEOUT_MS = 15_000;
9
- /** Horizon base URL. Defaults to Pi testnet; override with PION_HORIZON_URL. */
10
- export const HORIZON_URL = (process.env.PION_HORIZON_URL ?? DEFAULT_HORIZON_URL).replace(/\/+$/, "");
9
+ /**
10
+ * Horizon base URL for the selected network.
11
+ *
12
+ * Derived from the resolved network rather than read from the environment
13
+ * directly, so there is exactly one place that decides which chain we are on.
14
+ * `PION_NETWORK` and `PION_HORIZON_URL` are both handled in networks.ts.
15
+ */
16
+ export const HORIZON_URL = NETWORK.horizonUrl;
11
17
  /** A Horizon request that failed — network, timeout, or non-2xx response. */
12
18
  export class HorizonError extends Error {
13
19
  status;
@@ -55,7 +61,15 @@ async function describeFailure(response, path) {
55
61
  // Non-JSON error body — fall through to the generic message.
56
62
  }
57
63
  if (response.status === 404) {
58
- return `Not found on Horizon (${HORIZON_URL}${path}). The account or transaction does not exist on this network, or has never been funded.`;
64
+ // Name the network. A common cause of a surprising 404 is looking for an
65
+ // account on the chain it does not live on, and that is invisible unless
66
+ // said. Note the asymmetry: a 404 is evidence the address is absent *here*,
67
+ // but a success is not evidence you asked the right chain — some addresses
68
+ // exist on both with different balances (docs/FINDINGS.md, finding 5).
69
+ return (`Not found on ${NETWORK.label} Horizon (${HORIZON_URL}${path}). The account or ` +
70
+ `transaction does not exist on ${NETWORK.label}, or has never been funded. ` +
71
+ "Pi Mainnet and Pi Testnet are separate ledgers sharing one address format, so " +
72
+ "this address may still be real on the other chain.");
59
73
  }
60
74
  const parts = [problem.title, problem.detail, problem.extras?.reason].filter(Boolean);
61
75
  return parts.length > 0
@@ -79,4 +93,3 @@ export function cursorFromLink(href) {
79
93
  return undefined;
80
94
  }
81
95
  }
82
- //# sourceMappingURL=horizon.js.map
package/dist/index.js CHANGED
@@ -7,25 +7,34 @@
7
7
  * Horizon API. No credentials of any kind.
8
8
  * Tier B — verify_user, which validates a *user* access token supplied by
9
9
  * the caller against the Platform API.
10
+ * Tier C — send_payment (A2U), which MOVES REAL FUNDS from the app wallet.
11
+ * Disabled unless explicitly armed; see src/payments.ts.
10
12
  *
11
- * No server API key or wallet secret is read anywhere in this server, and no
12
- * tool can move value.
13
+ * Tiers A and B read no credentials from the environment and cannot move
14
+ * value. Tier C is the sole exception and is off by default.
15
+ *
16
+ * Networks: Tier A reads either Pi chain, selected with PION_NETWORK and
17
+ * reported in the `network` field of every result. Tier C is testnet-only —
18
+ * Pi restricts A2U to testnet, so mainnet selection makes payments unarmable
19
+ * rather than merely discouraged. See src/networks.ts and src/payments.ts.
13
20
  */
14
21
  import { createRequire } from "node:module";
15
22
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
23
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
24
  import { HORIZON_URL } from "./horizon.js";
25
+ import { NETWORK, NETWORK_ERROR, NETWORK_WARNING } from "./networks.js";
26
+ import { checkPaymentsArming } from "./payments.js";
18
27
  import { PLATFORM_URL } from "./platform.js";
19
28
  import { registerGetAccountPayments } from "./tools/get-account-payments.js";
20
29
  import { registerGetWalletBalance } from "./tools/get-wallet-balance.js";
21
30
  import { registerQueryTransaction } from "./tools/query-transaction.js";
31
+ import { registerSendPayment } from "./tools/send-payment.js";
22
32
  import { registerVerifyUser } from "./tools/verify-user.js";
23
33
  // Single source of truth for the version. `../package.json` resolves to the
24
34
  // package root from both dist/index.js and src/index.ts, so this is correct
25
35
  // whether running the build or the sources directly. Resolved at runtime
26
36
  // rather than imported so the JSON never has to be copied into dist/.
27
37
  const { version: VERSION } = createRequire(import.meta.url)("../package.json");
28
- const NETWORK = HORIZON_URL.includes("testnet") ? "Pi Testnet" : `custom (${HORIZON_URL})`;
29
38
  if (process.argv.includes("--version") || process.argv.includes("-v")) {
30
39
  process.stdout.write(`${VERSION}\n`);
31
40
  process.exit(0);
@@ -38,18 +47,38 @@ if (process.argv.includes("--help") || process.argv.includes("-h")) {
38
47
  "invoking it directly.",
39
48
  "",
40
49
  "Tools: get_wallet_balance, get_account_payments, query_transaction, verify_user",
50
+ " send_payment (only when explicitly armed — see below)",
41
51
  "",
42
52
  "Environment:",
43
- " PION_HORIZON_URL Horizon base URL (default: https://api.testnet.minepi.com)",
53
+ " PION_NETWORK testnet (default) or mainnet — which chain the read tools query",
54
+ " PION_HORIZON_URL Horizon base URL, overriding PION_NETWORK's default",
44
55
  " PION_PLATFORM_URL Platform API base URL (default: https://api.minepi.com)",
45
56
  "",
57
+ "Reads work on both Pi chains. Payments do not: Pi restricts App-to-User",
58
+ "payments to testnet, so send_payment cannot be armed when PION_NETWORK=mainnet.",
59
+ "",
60
+ "Arming send_payment (all four required; testnet only):",
61
+ " PION_ENABLE_PAYMENTS=1 explicit switch, separate from credentials",
62
+ " PION_MAX_PAYMENT_PI required per-payment ceiling, in Pi",
63
+ " PI_SERVER_API_KEY Pi Developer Portal server API key",
64
+ " PI_WALLET_SECRET app wallet secret seed (S...)",
65
+ "",
46
66
  ].join("\n"));
47
67
  process.exit(0);
48
68
  }
69
+ // A network we cannot identify is a network we will not serve. Reported here,
70
+ // after --help/--version, so a misconfigured operator can still read the usage.
71
+ if (NETWORK_ERROR) {
72
+ console.error(`pion-mcp: ${NETWORK_ERROR.message}`);
73
+ process.exit(1);
74
+ }
49
75
  const server = new McpServer({ name: "pion-mcp", version: VERSION }, {
50
76
  instructions: `Pion exposes read-only Pi Network data. get_wallet_balance, get_account_payments, ` +
51
77
  `and query_transaction are public ledger reads from Horizon at ${HORIZON_URL} ` +
52
- `(${NETWORK}), needing no credentials. Amounts are decimal strings; Pi itself is ` +
78
+ `(${NETWORK.label}), needing no credentials. This server is reading ` +
79
+ `${NETWORK.label} — every result repeats it in its "network" field, and the two Pi ` +
80
+ `chains are separate ledgers, so an address funded on one does not exist on the ` +
81
+ `other. Amounts are decimal strings; Pi itself is ` +
53
82
  'reported as the asset "PI", custom tokens as "CODE:ISSUER", and liquidity-pool ' +
54
83
  'shares as "pool:ID". verify_user is different: it checks a user access token ' +
55
84
  `against the Pi Platform API at ${PLATFORM_URL} and requires the caller to supply ` +
@@ -59,13 +88,32 @@ registerGetWalletBalance(server, NETWORK);
59
88
  registerGetAccountPayments(server, NETWORK);
60
89
  registerQueryTransaction(server, NETWORK);
61
90
  registerVerifyUser(server);
91
+ // Tier C is registered only when fully armed. A disarmed server does not
92
+ // advertise a payment tool at all, so an agent cannot try to spend and cannot
93
+ // be talked into thinking it might succeed.
94
+ //
95
+ // Passed the resolved network, not the URL: arming turns on what chain this
96
+ // *is*, and a string containing "testnet" is not the same claim.
97
+ const payments = checkPaymentsArming(NETWORK);
98
+ if (payments.armed) {
99
+ registerSendPayment(server, payments.config);
100
+ }
62
101
  async function main() {
63
102
  // stdout is the JSON-RPC channel — every log line must go to stderr.
64
103
  await server.connect(new StdioServerTransport());
65
- console.error(`pion-mcp ${VERSION} ready on stdio Horizon: ${HORIZON_URL} (${NETWORK})`);
104
+ // The chain is stated first and unabbreviated. A user who never sees this
105
+ // line still gets it on every result, but the one who does see it should not
106
+ // have to infer mainnet from a hostname.
107
+ const emphasis = NETWORK.id === "mainnet" ? " — REAL VALUE" : "";
108
+ console.error(`pion-mcp ${VERSION} ready on stdio — reading ${NETWORK.label}${emphasis} ` +
109
+ `(Horizon: ${HORIZON_URL})`);
110
+ if (NETWORK_WARNING)
111
+ console.error(`⚠ ${NETWORK_WARNING}`);
112
+ console.error(payments.armed
113
+ ? `⚠ send_payment ARMED — can spend up to ${payments.config.maxAmountPi} Pi per call from the app wallet`
114
+ : `send_payment disabled — ${payments.reason}`);
66
115
  }
67
116
  main().catch((error) => {
68
117
  console.error("pion-mcp failed to start:", error);
69
118
  process.exit(1);
70
119
  });
71
- //# sourceMappingURL=index.js.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Which Pi chain this server reads.
3
+ *
4
+ * One resolved object is the single source of truth. Everything downstream —
5
+ * the Horizon client, the startup banner, the `network` field on every read
6
+ * result, and the payments arming check — reads it instead of re-deriving the
7
+ * network by sniffing a URL string.
8
+ *
9
+ * That is the point. Before v0.4 the network was inferred in two places by
10
+ * testing `HORIZON_URL.includes("testnet")`: once for the banner, once for
11
+ * arming. Two independent sniffs of one string is how a configuration ends up
12
+ * testnet by one check and mainnet by another.
13
+ *
14
+ * Values below were verified against the live nodes on 2026-08-14, not assumed
15
+ * from the naming pattern. See docs/pi-sdk-notes.md, "Layer 3".
16
+ */
17
+ export type NetworkId = "testnet" | "mainnet" | "custom";
18
+ export interface PiNetwork {
19
+ id: NetworkId;
20
+ /** Horizon base URL, no trailing slash. */
21
+ horizonUrl: string;
22
+ /** Human-facing name, for banners and tool output. NOT a signing input. */
23
+ label: string;
24
+ /**
25
+ * The Stellar network passphrase, as reported by the node itself.
26
+ * `undefined` for a custom endpoint, whose chain identity we cannot vouch for.
27
+ */
28
+ passphrase: string | undefined;
29
+ /** Block explorer base, for pointing a human at independent confirmation. */
30
+ explorerUrl: string | undefined;
31
+ /**
32
+ * True only for the genuine Pi Testnet. This is the flag payments arm on;
33
+ * nothing else may set it, including a custom endpoint that looks testnet-ish.
34
+ */
35
+ isTestnet: boolean;
36
+ }
37
+ export declare const PI_TESTNET: PiNetwork;
38
+ export declare const PI_MAINNET: PiNetwork;
39
+ /**
40
+ * Pi publishes a secondary mainnet Horizon at https://api2.mainnet.minepi.com
41
+ * (from `REACT_APP_MAINNET_SECONDARY_API_URL` in the explorer's production
42
+ * build). It is live and reports the same passphrase.
43
+ *
44
+ * Deliberately not wired as automatic failover. Two hosts silently serving one
45
+ * client can disagree — different ingestion lag, one stale — and a read that
46
+ * quietly changes source is exactly the kind of thing that makes a later
47
+ * "the balance was wrong" impossible to reconstruct. Set PION_HORIZON_URL to it
48
+ * by hand if the primary is down.
49
+ */
50
+ export declare const PI_MAINNET_SECONDARY_URL = "https://api2.mainnet.minepi.com";
51
+ /** A `PION_NETWORK` / `PION_HORIZON_URL` combination we refuse to guess at. */
52
+ export declare class NetworkConfigError extends Error {
53
+ constructor(message: string);
54
+ }
55
+ export interface Resolution {
56
+ network: PiNetwork;
57
+ /** Non-fatal note for the operator, printed at startup. */
58
+ warning?: string;
59
+ }
60
+ /**
61
+ * Resolves the network from the environment.
62
+ *
63
+ * Pure and env-injectable so the guards can be tested without mutating the
64
+ * real process environment.
65
+ */
66
+ export declare function resolveNetwork(env?: NodeJS.ProcessEnv): Resolution;
67
+ export declare const NETWORK: PiNetwork;
68
+ export declare const NETWORK_WARNING: string | undefined;
69
+ export declare const NETWORK_ERROR: NetworkConfigError | undefined;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Which Pi chain this server reads.
3
+ *
4
+ * One resolved object is the single source of truth. Everything downstream —
5
+ * the Horizon client, the startup banner, the `network` field on every read
6
+ * result, and the payments arming check — reads it instead of re-deriving the
7
+ * network by sniffing a URL string.
8
+ *
9
+ * That is the point. Before v0.4 the network was inferred in two places by
10
+ * testing `HORIZON_URL.includes("testnet")`: once for the banner, once for
11
+ * arming. Two independent sniffs of one string is how a configuration ends up
12
+ * testnet by one check and mainnet by another.
13
+ *
14
+ * Values below were verified against the live nodes on 2026-08-14, not assumed
15
+ * from the naming pattern. See docs/pi-sdk-notes.md, "Layer 3".
16
+ */
17
+ export const PI_TESTNET = {
18
+ id: "testnet",
19
+ horizonUrl: "https://api.testnet.minepi.com",
20
+ label: "Pi Testnet",
21
+ passphrase: "Pi Testnet",
22
+ explorerUrl: "https://blockexplorer.minepi.com/testnet",
23
+ isTestnet: true,
24
+ };
25
+ export const PI_MAINNET = {
26
+ id: "mainnet",
27
+ horizonUrl: "https://api.mainnet.minepi.com",
28
+ label: "Pi Mainnet",
29
+ /**
30
+ * "Pi Network" — NOT "Pi Mainnet".
31
+ *
32
+ * This is the one value here that the naming pattern gets wrong, and getting
33
+ * it wrong is expensive: the passphrase is hashed into every signature, so a
34
+ * transaction signed against the wrong string is invalid on the chain it was
35
+ * meant for.
36
+ *
37
+ * Verified three ways on 2026-08-14:
38
+ * 1. `GET https://api.mainnet.minepi.com/` reports
39
+ * `"network_passphrase": "Pi Network"`. For a Stellar network the node's
40
+ * own answer is definitive — it is the string it validates against.
41
+ * 2. The secondary node, api2.mainnet.minepi.com, reports the same.
42
+ * 3. The string "Pi Mainnet" *does* appear in Pi's production explorer
43
+ * bundle, but only as English UI copy ("...not been activated on the Pi
44
+ * Mainnet yet"). It is a display label that reads like corroboration and
45
+ * is not. Hence `label` and `passphrase` are separate fields here.
46
+ */
47
+ passphrase: "Pi Network",
48
+ explorerUrl: "https://blockexplorer.minepi.com/mainnet",
49
+ isTestnet: false,
50
+ };
51
+ /**
52
+ * Pi publishes a secondary mainnet Horizon at https://api2.mainnet.minepi.com
53
+ * (from `REACT_APP_MAINNET_SECONDARY_API_URL` in the explorer's production
54
+ * build). It is live and reports the same passphrase.
55
+ *
56
+ * Deliberately not wired as automatic failover. Two hosts silently serving one
57
+ * client can disagree — different ingestion lag, one stale — and a read that
58
+ * quietly changes source is exactly the kind of thing that makes a later
59
+ * "the balance was wrong" impossible to reconstruct. Set PION_HORIZON_URL to it
60
+ * by hand if the primary is down.
61
+ */
62
+ export const PI_MAINNET_SECONDARY_URL = "https://api2.mainnet.minepi.com";
63
+ const KNOWN = {
64
+ testnet: PI_TESTNET,
65
+ mainnet: PI_MAINNET,
66
+ };
67
+ /** A `PION_NETWORK` / `PION_HORIZON_URL` combination we refuse to guess at. */
68
+ export class NetworkConfigError extends Error {
69
+ constructor(message) {
70
+ super(message);
71
+ this.name = "NetworkConfigError";
72
+ }
73
+ }
74
+ /**
75
+ * Resolves the network from the environment.
76
+ *
77
+ * Pure and env-injectable so the guards can be tested without mutating the
78
+ * real process environment.
79
+ */
80
+ export function resolveNetwork(env = process.env) {
81
+ const requestedRaw = env.PION_NETWORK?.trim();
82
+ const requested = requestedRaw?.toLowerCase();
83
+ if (requested !== undefined && requested !== "" && KNOWN[requested] === undefined) {
84
+ throw new NetworkConfigError(`PION_NETWORK must be "testnet" or "mainnet", got "${requestedRaw}". ` +
85
+ "Refusing to start rather than guessing which chain you meant.");
86
+ }
87
+ const selected = requested ? KNOWN[requested] : PI_TESTNET;
88
+ const override = env.PION_HORIZON_URL?.trim().replace(/\/+$/, "");
89
+ if (!override)
90
+ return { network: selected };
91
+ const matched = Object.values(KNOWN).find((candidate) => candidate.horizonUrl === override);
92
+ if (matched) {
93
+ // Both variables are set and they name different chains. One of them is a
94
+ // mistake and we cannot tell which, so stop instead of picking a winner.
95
+ if (requested && matched.id !== selected.id) {
96
+ throw new NetworkConfigError(`PION_NETWORK=${selected.id} and PION_HORIZON_URL=${override} name different ` +
97
+ `networks (${selected.label} vs ${matched.label}). Set one or the other, not both.`);
98
+ }
99
+ return { network: matched };
100
+ }
101
+ // An endpoint we have not verified. It may well be a proxy in front of the
102
+ // real testnet, but we cannot confirm that from here, and "probably testnet"
103
+ // is not a basis for arming a spend. Label it honestly as unknown.
104
+ return {
105
+ network: {
106
+ id: "custom",
107
+ horizonUrl: override,
108
+ label: `custom endpoint (${override})`,
109
+ passphrase: undefined,
110
+ explorerUrl: undefined,
111
+ isTestnet: false,
112
+ },
113
+ warning: `PION_HORIZON_URL points at ${override}, which is not a Pi network Pion knows. ` +
114
+ "Reads will be attempted against it and reported as an unverified chain; " +
115
+ "payments cannot be armed against a custom endpoint.",
116
+ };
117
+ }
118
+ /**
119
+ * Resolved once at startup. On a configuration error this falls back to testnet
120
+ * — the safe default — and records the error for `index.ts` to report before it
121
+ * serves anything. Throwing from module scope would surface as a bare stack
122
+ * trace from an import, which is not a useful thing to hand an operator.
123
+ */
124
+ let resolutionError;
125
+ let resolved;
126
+ try {
127
+ resolved = resolveNetwork();
128
+ }
129
+ catch (error) {
130
+ resolutionError = error;
131
+ resolved = { network: PI_TESTNET };
132
+ }
133
+ export const NETWORK = resolved.network;
134
+ export const NETWORK_WARNING = resolved.warning;
135
+ export const NETWORK_ERROR = resolutionError;