Skip to content

Add CTX spend-api pubkey auth prototype - #6147

Open
j0ntz wants to merge 4 commits into
developfrom
jon/ctx-spend-prototype
Open

j0ntz wants to merge 4 commits into
developfrom
jon/ctx-spend-prototype

Conversation

@j0ntz

@j0ntz j0ntz commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Technical Design Document

ctx-spend-pubkey-auth.md

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Description

Asana: https://app.asana.com/0/1215088146871429/1216403654258326

CTX published a reference script for an anonymous secp256k1 register/login protocol against their spend-api (CTX-com/spend-api-pubkey-auth-demo). This is a prototype client for that protocol, wired into the existing EdgeSpend gift card plugin.

GET /me against CTX returns gift-card-scoped permissions (giftcard:create, giftcard:list, giftcard:redeem, merchant:list) and GET /merchants returns a 118-entry brand catalog, so CTX is a competing provider for what the Phaze plugin already does. The client is added alongside Phaze; the shipping purchase flow is untouched.

The design doc committed in this PR covers the protocol, the decisions, and the two places the reference script's crypto could not be ported verbatim: src/docs/ctx-spend-pubkey-auth.md.

What is here:

  • ctxSpendPurchase.ts plus createGiftCard/getGiftCard — ordering a card and paying for it. POST /gift-cards returns the card already carrying a single-use payment address and a crypto amount quoted at rate; the client maps CTX's chain-and-network pair to an Edge currency plugin, converts the quote to native units with biggystring, and hands it to the ordinary send scene with the address, amount, and wallet tiles locked. The card is then polled until it reaches an end state.

  • Chain coverage for every mainnet asset CTX quotes: BCH, BTC, DASH, ETH, LTC, XLM, XMR, ZANO and ZEC. Each chain code was read back from a real POST /gift-cards response, one order per cryptoCurrency, rather than transcribed from a currency list.

  • A native-payment guard. A token quote splits the pair (paymentCryptoChain stays ETH while paymentCryptoCurrency becomes ETH.USDC), and both the wallet lookup and the native-unit conversion key off the chain alone, so a 0.010000 USDC quote would have resolved the ethereum wallet and sent 0.01 ETH for a one-cent card. isCtxNativePayment rejects it instead.

  • CTX's documented status machine: unpaid, paid, fulfilled (terminal success), rejected (which can still move to refunded), refunded (terminal). rejected is a failure but deliberately not terminal, so it does not stop the poll. The poll now runs at 5s until paid and backs off to 30s afterwards, because fulfilment is the merchant's and can outlast the session.

  • redeemUrl and barcodeUrl on the card. Redemption is not a path the client builds: a fulfilled card carries both URLs, each embedding its own JWT distinct from the session token. That is why a hand-built /gift-cards/{id}/barcode answers 401 token invalid for a session token every other route accepts.

  • ctxSpendCrypto.ts — the protocol wire format: sha256(uint64BE(nonce)) digest, the 65-byte recoverable signature ([27 + recoveryId + 4] || R || S), public key recovery, and JWT expiry parsing. No React Native imports, so it is unit tested in Node.

  • ctxSpendAuth.ts — the keypair is the CTX account. It is generated on first use and kept in the Edge account's encrypted dataStore, so the same anonymous CTX user is recovered across launches and devices. The reference script deliberately discards its keypair; a real client cannot. Tokens are derived state and are never persisted.

  • ctxSpendApi.ts — the confirmed read surface (/me, /merchants, /gift-cards), with one re-auth retry on a 401.

  • PLUGIN_API_KEYS.ctxSpend (clientId, baseUrl). There is no API key; the client id is registered server-side and the keypair identifies the user.

  • A CTX section on the existing developer-facing gift card account info scene, so the session can be driven in the app.

Two substitutions the runtime forced, both covered in the doc's Decisions:

  • No BigInt in the uint64 encoding. index.ts shims BigInt with big-integer when it is missing, and DataView.setBigUint64 rejects that shim.
  • Request URLs are built by string, not via URL. React Native's URL appends a trailing slash to any query-less path, and the spend-api routes /me/ and /merchants/ to 404.

No new npm dependency: @noble/curves and @noble/hashes were already present, and are the same libraries the reference script uses.

Not in scope: replacing Phaze, following the redemption links (their shape is now known and modelled, but nothing opens them), a merchant-browsing UI, a shared provider interface over both APIs, and identity rotation.

Test plan

  • 34 jest cases across ctxSpendCrypto, ctxSpendAuth and ctxSpendPurchase. The load-bearing one recovers the signing public key from the signature, which is the same check the server performs, so the wire format is verified without a network round trip. Vectors were cross-checked against Buffer.writeBigUInt64BE and node:crypto.
  • verify-repo.sh passes: eslint, npm run prepare, and the full 536-case suite.
  • Driven in-app on an iOS 18.6 simulator against https://staging.spend.ctx.com. The app generated its own keypair, registered it, and read live data: 12 permissions, 118 merchants, company Edge. The server named the anonymous user Anon 032129df after the public key's own prefix.
  • Persistence checked by a full app stop and relaunch: the same user id and public key came back, so the keypair is reloaded from the encrypted store rather than regenerated.

End-to-end purchase, driven in-app on the sim against staging: ordered an Amazon card for USD 0.01, paid the quoted 0.0000052 ETH from the account's Sepolia wallet through the normal send flow, and watched CTX move the card to paid. Card 5d2964f9-3641-450e-8d77-e9f178b5b1da.

Every staging quote is testnet (Monero on stagenet, Zcash/BTC/LTC/BCH/DASH/XLM/ZANO on theirs, ETH on Sepolia), and Sepolia is the only testnet Edge ships a wallet for, so ETH is the one rail the app can pay against staging. The mainnet rows of the chain table are therefore unexercised: CTX's production endpoint exists but does not yet permit pubkey auth for our client (400 authentication method not allowed for this client on the first POST /login leg, against a 200 from the identical request to staging).

Re-driven end to end after those changes: ordered another Amazon card at USD 0.01, paid 0.000004 ETH from the Sepolia wallet, and watched CTX move it to paid. Card 99f59d44-216c-4a0f-b620-27cdfed996d1. The redemption-link rows cannot be reached naturally, since staging never fulfils a card, so that frame was captured under a forced card state and is labelled as such in the evidence comment.

Screenshots in the test-evidence comments below.

Bugbot found 6 issues across three rounds, all fixed and re-tested on the sim. Four were in the first round: a dataStore read failure reading as "no identity" and silently minting a replacement keypair (the worst of them, since CTX has no recovery), malformed stored hex throwing past the replace path, a light-account status conflated with a failed write, and Connect not being retryable. The retry fix then surfaced two follow-ons it had created or exposed: an ungated button that could stack sessions, and the app-wide retry: 2 turning one failed connect into three login handshakes against a rate-limited API. Decision 8.6 in the design doc records the error-handling contract that came out of the first round.

Requirements

If you have made any visual changes to the GUI. Make sure you have:

  • Tested on iOS device
  • Tested on Android device
  • Tested on small-screen device (iPod Touch)
  • Tested on large-screen device (tablet)

Note

Medium Risk
Prototype persists signing keys and can trigger real on-chain sends when used, but it is developer-gated and does not alter the shipping Phaze purchase path; mistakes in amount mapping or identity handling could still strand orders or keys.

Overview
Adds a developer-mode prototype for CTX’s spend-api alongside the existing Phaze gift card flow: anonymous secp256k1 pubkey register/login, JWT session handling with keys in encrypted account.dataStore, and HTTP clients for /me, /merchants, and /gift-cards.

From Gift Card Account Info, developers can connect (show pubkey, user, merchant count), order a fixed staging test card, pay via the normal send scene (chain/network → Edge wallet, native amount with ceiling rounding, native-only quotes), and poll card status until terminal (fulfilled / refunded) with faster polling until paid.

New modules: ctxSpendCrypto, ctxSpendAuth, ctxSpendApi, ctxSpendPurchase, ctxSpendTypes; PLUGIN_API_KEYS.ctxSpend in env; locales; design doc; Jest coverage for crypto, identity races, and payment/status helpers. Phaze reveal/copy behavior is unchanged; CTX UI is gated on developer mode and env config.

Reviewed by Cursor Bugbot for commit 867f3b5. Bugbot is set up for automated code reviews on this repo. Configure here.

@j0ntz

j0ntz commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor Author

📸 Test evidence

ctx before connect

ctx before connect

ctx authenticated

ctx authenticated

ctx persisted after relaunch

ctx persisted after relaunch

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/ctx-spend-prototype branch from 536021a to 09ab729 Compare August 10, 2026 21:02
@j0ntz
j0ntz marked this pull request as ready for review August 10, 2026 21:02
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread src/components/scenes/GiftCardAccountInfoScene.tsx
Comment thread src/components/scenes/GiftCardAccountInfoScene.tsx Outdated
Comment thread src/plugins/gift-cards/ctxSpendAuth.ts Outdated
Comment thread src/plugins/gift-cards/ctxSpendAuth.ts
Implements the anonymous secp256k1 register/login protocol documented by
CTX-com/spend-api-pubkey-auth-demo, plus the read endpoints it unlocks.

The keypair is the CTX account: it is generated on first use and kept in the
Edge account's encrypted dataStore, so the same anonymous CTX user is recovered
across launches and devices. Tokens are derived state and are never persisted.

The protocol crypto is kept free of React Native imports so its wire format is
unit testable in Node, and it avoids BigInt because Hermes falls back to a
big-integer shim that DataView.setBigUint64 rejects.
@j0ntz
j0ntz force-pushed the jon/ctx-spend-prototype branch from 09ab729 to 428ff98 Compare August 10, 2026 21:16
Comment thread src/components/scenes/GiftCardAccountInfoScene.tsx Outdated
@j0ntz
j0ntz force-pushed the jon/ctx-spend-prototype branch from 428ff98 to 7151140 Compare August 10, 2026 21:25
Comment thread src/components/scenes/GiftCardAccountInfoScene.tsx
j0ntz added 2 commits August 10, 2026 14:34
Gives the pubkey auth prototype a surface that can be driven in the running
app: connecting establishes the keypair identity, logs in, and reads the
authenticated user, company, permission count, and merchant catalog size.

The scene already exists as the developer-facing readout for this plugin, so
the prototype rides along without touching the shipping purchase flow.
Documents the protocol as implemented: the wire format, where the keypair
lives and why, the token lifecycle, and the decisions behind each, including
the two places the reference script's crypto could not be copied verbatim.
@j0ntz
j0ntz force-pushed the jon/ctx-spend-prototype branch from 7151140 to e222388 Compare August 10, 2026 21:35
@j0ntz

j0ntz commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (after review fixes)

ctx persisted after relaunch

ctx persisted after relaunch

Captured by the agent's in-app test run (build-and-test).

@j0ntz

j0ntz commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor Author

📸 Test evidence: end-to-end gift card purchase (Amazon USD 0.01, paid on Sepolia)

ctx connected

ctx connected

ctx send prefilled

ctx send prefilled

ctx card after payment

ctx card after payment

ctx card paid

ctx card paid

Captured by the agent's in-app test run (build-and-test).

Comment thread src/components/scenes/GiftCardAccountInfoScene.tsx Outdated
Comment thread src/components/scenes/GiftCardAccountInfoScene.tsx
Comment thread src/locales/en_US.ts Outdated
@j0ntz
j0ntz force-pushed the jon/ctx-spend-prototype branch from 2e03949 to 8d13b14 Compare August 17, 2026 21:40
Comment thread src/plugins/gift-cards/ctxSpendAuth.ts Outdated
@j0ntz
j0ntz force-pushed the jon/ctx-spend-prototype branch from 8d13b14 to c35d4f3 Compare August 17, 2026 21:49
@j0ntz
j0ntz force-pushed the jon/ctx-spend-prototype branch from c35d4f3 to e206556 Compare September 4, 2026 08:51
@j0ntz

j0ntz commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor Author

📸🪓 Test evidence: CTX mainnet chain map, status machine, redemption links

🪓 Hack-forced evidence: pinned the card query's displayStatus to 'fulfilled' and injected placeholder redeemUrl/barcodeUrl, since CTX staging never fulfils a card; reverted, tree clean. Temporary uncommitted edit, reverted before commit; the marked frames prove the rendering, not the trigger.

ctx session

ctx session

ctx payment locked

ctx payment locked

ctx payment sent

ctx payment sent

ctx card paid

ctx card paid

🪓 HACK-FORCED: redeem links

🪓 HACK-FORCED: redeem links

Captured by the agent's in-app test run (build-and-test).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e206556. Configure here.

Comment thread src/components/scenes/GiftCardAccountInfoScene.tsx
Orders a card with POST /gift-cards, which allocates a payment address and
quotes the crypto amount, then hands that address and amount to the normal
send flow and polls the card until CTX reports it fulfilled.

The request field names and the order shape are what staging actually
accepts; the placeholder gift card cleaner from the read-only pass was a
guess and is replaced by the real one.

Staging quotes every asset on testnet, so the payment resolves to the one
testnet chain Edge carries a wallet for, sepolia.
@j0ntz
j0ntz force-pushed the jon/ctx-spend-prototype branch from e206556 to 867f3b5 Compare September 4, 2026 09:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant