Skip to content

Onboard router: one-method onboarding with on-chain capability discovery (CAP-68 + CAP-73)#16

Open
willemneal wants to merge 34 commits into
mainfrom
feat/usdc-open-asset-e2e
Open

Onboard router: one-method onboarding with on-chain capability discovery (CAP-68 + CAP-73)#16
willemneal wants to merge 34 commits into
mainfrom
feat/usdc-open-asset-e2e

Conversation

@willemneal

@willemneal willemneal commented Jun 8, 2026

Copy link
Copy Markdown
Member

Replaces the asset-dependent two-path onboarding flow with a single Soroban "discovery router" method that learns an asset's trust-establishment capability on-chain, so the client never branches on per-asset config. Built TDD from the committed design spec + plan (in docs/superpowers/; the older two-path design is retained there, marked superseded).

The pivot

One method does it all:

onboard(env, sac: Address, holder: Address) -> Result<OnboardStatus, Error>
  • holder.require_auth() — the holder signs once, over the whole auth tree.
  • CAP-68 sac.executable() — refuse a sac that isn't a real Stellar Asset Contract (on-chain anti-copycat), then detect whether the SAC admin is a Wasm contract.
  • CAP-73 trust() — idempotent; creates the trustline (no-op if it exists). Open asset (!AUTH_REQUIRED) → already authorized → Authorized.
  • Otherwise call try_authorize_trustline(holder) on the discovered admin and classify: Ok + authorized → Authorized; Ok + still unauthorized → Err(NotAuthorized); a typed contract error → Err(AuthorizationRefused); any other recoverable error (no export / panic / non-contract admin) → Ok(TrustlineOnly).

OnboardStatus = { Authorized, TrustlineOnly }; Error = { NotSac=1, TrustFailed=2, AuthorizationRefused=3, NotAuthorized=4 }. The try-call classification is verified against soroban-env-host 26.1.3 (a 6-test spike pins the env semantics).

Why on-chain discovery: only a SAC's admin can set_authorized, so "the admin exports authorize_trustline" is the definition of one-step capability — the old authorizer config/toml field was a spoofable copy of sac.admin(). Discovering it on-chain removes the trust assumption.

Changes

  • Contract (contracts/trustline-onboard) — the 2-arg discovery router; 16 Rust tests (10 scenario + 6 env-classification), incl. typed-rejection revert, no-export/panicking admin → TrustlineOnly, post-condition NotAuthorized, impostor-SAC rejection, real G-account holder. # Security documents the holder-signed auth tree over the asset-chosen admin's sub-invocations.
  • SDK (packages/authline-sdk, 0.3.0) — buildOnboardTx targets the router; buildTrustTx + the old 3-arg onboard deleted. Per-network ROUTERS singleton pinned + StrKey-validated; reconcileWithRegistry checks the advertised router against the pin even for uncurated codes (asset-independent). Shared decodeOnboardStatus decodes the router's return value; the React hook + ActivateButton now report TrustlineOnly truthfully instead of "Activated" on every tx success.
  • Reads via Stellar RPC — Horizon droppedgetActivationStatus reads the trustline straight from the ledger (getLedgerEntries); assetAuthRequired deleted (capability is discovered on-chain); buildSponsoredOnboardTx uses rpc.getAccount. One endpoint, one allowHttp (localhost-only by default), and the prior "insecure horizon server" failure class is gone.
  • dApp (src/) — single activate() path through the router; truthful success state decoded from OnboardStatus ("trustline created" vs "trustline authorized"); a missing/undecodable outcome falls back to the static capability so it never over-claims authorization; missing-router gates the CTA. Live asset defaults network-aware (mainnet → EURCV, every other network → the pinned testnet USDC) so a dev on testnet gets a working asset.
  • SEP draft (sep/SEP-XXXX-…) — v0.3: verbatim contract code, the typed-error-MUST rule, the OnboardStatus/router model; records the v0.3 discovery-router testnet run.
  • CI — the build job now runs npm run test:contracts (the router's correctness lives in those Rust tests); the workflow-dispatch e2e-testnet job runs the full tests/e2e suite (no longer orphaning the TLO discovery test) with a concurrency guard.
  • Deploy — router pinned on testnet (CABVVUYHXS6UVN2VYYXKEUO2XEJIAGMTEYF2BOWGUUJVOO2IGPRWZAX4). Mainnet deploy is a tracked follow-up, gated on a fresh on-chain EURCV sac.admin() read.

Test Plan

  • npm run lint · npm run typecheck · npm run build — green
  • npx vitest run26 passed, 2 real-chain e2e skipped by default (RUN_TESTNET_E2E gate)
  • npm run test:contracts17 Rust tests pass (16 router + 1 authorizer-stub; cargo test -p trustline-onboard -p authorizer-stub)
  • npm run test:e2e:testnet2 passed on real testnet: USDC open → Authorized; TLO AUTH_REQUIRED → trustline created AND authorized via on-chain admin discovery ({ hasTrustline: true, isAuthorized: true }, read back over RPC)
  • npm run test:e2e — Playwright browser e2e 1 passed on real testnet (directory → connect → activate → authorized USDC trustline)

Notes

  • CI gates PRs on the offline suite (incl. contract tests); the real-testnet e2e are workflow_dispatch-only.
  • Mainnet router deployment + EURCV one-step wiring are tracked follow-ups (not in this PR).

🤖 Generated with Claude Code

…ding + e2e

Adds the design for: a new SDK `buildTrustTx` (CAP-73 `SAC.trust(holder)`) for
open assets, adding testnet USDC to the pinned registry, making USDC the live
asset on testnet only (mainnet stays EURCV), and two real-testnet e2e layers
(Node/SDK + Playwright browser) plus Vitest unit tests.

Spec: docs/superpowers/specs/2026-06-08-usdc-e2e-and-open-asset-onboarding-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://theahaco.github.io/stellar-assets/pr-preview/pr-16/

Built to branch gh-pages at 2026-06-10 17:02 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

Task-by-task TDD plan derived from the approved design spec: Vitest tooling,
testnet USDC registry entry, buildTrustTx (CAP-73 SAC.trust), dApp capability
branch + e2e wallet seam, SAC deploy helper, .env.e2e, Node + Playwright
real-testnet e2e, and CI wiring. Concrete values (testnet USDC SAC/issuer/flags)
verified during planning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Dgetsylver

Copy link
Copy Markdown
Contributor

Reviewed the design — mechanism is verified-sound; a few concrete changes before code lands. I ran the open-asset path on real testnet against the repo's resolved SDK (14.5.0), and the core holds:

  • prepareTransaction on SAC.trust(holder) decodes + submits in pure JS — no P26 "Bad union switch" (that only hits the set_authorized flag-write, not trustline creation). tx 523d7ad5…
  • the resulting open trustline reports is_authorized=true → the status / "already authorized" / success screens work unchanged
  • SAC.trust ABI is a single Address arg (§5.1's flagged open item — confirmed)

Must-fix

  1. Registry entry is off vs the live issuer. Circle's testnet USDC issuer GBBD47IF… actually has auth_revocable=true (Horizon read), but §5.2 pins authRevocable:false — that suppresses the dApp's freeze warning (authline.tsx:583). Also homeDomain is centre.io, not circle.com. Suggest stamping verifiedAt only after the on-chain read.
  2. assetIssuer has no registry fallback. config.ts does PUBLIC_ASSET_ISSUER ?? <hardcoded EURCV issuer> (unlike sac, which falls back to pinned?.sac). So a testnet env that sets only PUBLIC_ASSET_CODE=USDC yields {code:USDC, issuer:EURCV}getActivationStatus queries the wrong issuer and never finds the trustline. §5.3's "config.ts needs no code change" doesn't hold — either also set PUBLIC_ASSET_ISSUER in the e2e env, or add a pinned?.issuer fallback (and state which).
  3. .env.e2e won't be auto-loaded. Vite only loads .env.[mode] under --mode; the spec's vite preview runs production mode → PUBLIC_ASSET_CODE unset → defaults to EURCV. Use --mode testnet (.env.testnet), or load via dotenv in the Playwright webServer.

Test gating

  1. The PR-gating unit test won't catch the only real risk. buildTrustTx.test.ts mocks prepareTransaction, so the blocking check never exercises the live decode; the layer that does (§6.4 testnet e2e) is opt-in/non-blocking, so a decode regression could merge green. Worth a thin blocking decode smoke (RPC prepareTransaction only — no fund/submit).
  2. The permissioned EURCV path has no e2e, and it's the fragile one — its set_authorized flag-write is exactly what breaks the JS decoder. A standing (non-blocking) check against the deployed wrapper (PUBLIC_ONBOARD=CCQJ53C6…) would guard it.

Spec clarity

  1. State the funding split. trust() has no sponsorship — it needs a funded holder (0.5 XLM reserve plus the Soroban resource/inclusion fees), so it can't onboard a brand-new zero-XLM user. The third-party "onboard during withdrawal" case still needs buildSponsoredOnboardTx (CAP-33). Worth noting it's retained, not superseded, so the sponsored path doesn't read as dead.
  2. "Mainnet stays EURCV" rests on a repo var (PUBLIC_STELLAR_NETWORK_PASSPHRASE), not a committed .env — and config falls back to testnet if unset (config.ts:23). Worth verifying the var + a "never set PUBLIC_ASSET_CODE in repo vars" guard.

Nice-to-have

  • Compile-time gate window.__AUTHLINE_E2E__ so it tree-shakes out of the prod bundle (non-custodial dApp — a wallet-override hook is worth keeping out of mainnet).
  • Open-asset UI copy is permissioned-specific ("Auth req." pill, "then authorizes the line") — branch on ASSET.capability.

Mechanism, ABI, and decode are verified on-chain, so the path to merge is mostly the registry entry + env wiring + CI gating. Nice spec.

willemneal and others added 7 commits June 8, 2026 18:07
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… assets

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
willemneal and others added 3 commits June 8, 2026 18:46
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-in)

Adds playwright.config.ts + tests/e2e/usdc-activation.spec.ts for a
browser-driven real-testnet test that signs with an injected keypair via
page.exposeFunction/__AUTHLINE_E2E__ seam (no wallet extension needed).
Registers playwright.config.ts in tsconfig.node.json include so the
eslint project-service can lint it. Adds allowDefaultProject for
src/*.test.ts in eslint.config.js to resolve a pre-existing project-
service gap for vitest files under src/. Adds test:e2e script to
package.json. Offline gates green: tsc -b, vitest (8p+1s), eslint.
Live run requires testnet network access (opt-in/CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the no-op `npm test --if-present` step with a real `Unit tests`
step running `npm test` (Vitest units). Add an `e2e-testnet` job guarded
by `if: github.event_name == 'workflow_dispatch'` so testnet flakiness
never blocks PRs. Add `workflow_dispatch:` to the `on:` block to allow
manual dispatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@socket-security

socket-security Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​playwright/​test@​1.60.010010010099100

View full report

@willemneal willemneal changed the title Design: USDC support, open-asset (CAP-73 trust) onboarding + e2e tests USDC support, open-asset (CAP-73 trust) onboarding + e2e tests Jun 9, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One router method onboard(sac, holder): CAP-68 get_address_executable +
SAC.admin() discover the asset's capability at execution time, replacing
the client-side open/permissioned branching (buildTrustTx vs 3-arg
onboard). Decisions: Abort->TrustlineOnly + SEP typed-error rule,
OnboardStatus enum, delete old paths outright, pivot PR #16 in place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@willemneal willemneal changed the title USDC support, open-asset (CAP-73 trust) onboarding + e2e tests Onboard router: one-method onboarding with on-chain capability discovery (CAP-68 + CAP-73) Jun 9, 2026
willemneal and others added 6 commits June 10, 2026 09:32
…s, typed-error rule

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…superseded wrapper note

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…UTER wiring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…uter CTA

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Horizon.Server was constructed bare (allowHttp defaulting to false) in
status.ts and exchange.ts, while every rpc.Server threaded
`allowHttp ?? defaultAllowHttp(url)`. So a local quickstart (http
localhost) Horizon threw "Cannot connect to insecure horizon server"
from getActivationStatus on wallet connect, surfacing in the dApp as
"Couldn't create trustline".

Thread allowHttp through every Horizon.Server (defaulting to the same
localhost-only defaultAllowHttp), export defaultAllowHttp from the SDK,
and derive the dApp's NETWORK.allowHttp from the configured URLs instead
of hardcoding false so the rpc submit path also works against a local
node. Remote endpoints stay https-only.

New status.test.ts pins the threading (localhost http allowed; remote
https stays secure; remote http stays refused).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
willemneal and others added 6 commits June 10, 2026 12:12
…ainnet

The live-asset CODE defaulted to "EURCV" regardless of network, so a dApp
pointed at testnet/local without PUBLIC_ASSET_CODE resolved mainnet EURCV
— which has no testnet issuer/SAC — and failed ("EURCV isn't available on
testnet"). Make the default network-aware: PUBLIC keeps EURCV (the
production target); every other network defaults to the pinned testnet
token (USDC), which has a real testnet issuer/SAC and activates cleanly.
NET_TAG is hoisted above CODE so it can drive the default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dApp's only Horizon use was getActivationStatus reading classic
trustline flags. Stellar RPC — the same endpoint that builds and submits
the onboard tx — serves the same data via
getLedgerEntries(LedgerKeyTrustLine), so the whole Horizon dependency,
and the insecure-horizon footgun class, goes away. One endpoint, one
allowHttp.

- getActivationStatus reads the trustline ledger entry over RPC and
  decodes AUTHORIZED_FLAG (horizonUrl -> rpcUrl).
- delete assetAuthRequired (dead since the router discovers capability
  on-chain; the client no longer pre-reads auth_required).
- buildSponsoredOnboardTx fetches its source account via rpc.getAccount
  (horizonUrl -> rpcUrl); the Horizon import is removed from the SDK.
- drop horizonUrl from the dApp NETWORK + getActivationStatus calls and
  the now-dead PUBLIC_STELLAR_HORIZON_URL from .env.e2e.

Both real-chain testnet e2e still pass (USDC -> Authorized; TLO
AUTH_REQUIRED -> created AND authorized, trustline status read via RPC).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…back)

The SDK React hook (useActivation / ActivateButton, the public
@theaha/authline/react surface) reported "Activated" on any tx SUCCESS,
ignoring the router's OnboardStatus — so it claimed full activation even
when the router returned TrustlineOnly (trustline created but not
authorized). Decode the return value and surface a trustlineOnly flag;
ActivateButton renders "Trustline created" vs "Activated ✓".

Extract the decode into a shared decodeOnboardStatus helper (handles the
unit-enum vec and bare-symbol shapes; returns null when absent or
undecodable) and use it in both the hook and the dApp, replacing the
dApp's inline decode. On an unknown/undecodable outcome the dApp now
falls back to the asset's static capability (\!IS_OPEN) so it never
renders the stronger "authorized" claim for a possibly-trustline-only
asset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The build job had the Rust toolchain (from the scaffold build) but never
ran the contract unit tests, where the router's core correctness lives
(discovery error-classification, CAP-68 gates) — a logic regression could
pass CI green. Add a "Contract tests" step (npm run test:contracts) and
extend that script to cover authorizer-stub too.

test:e2e:testnet was hardcoded to the USDC file, orphaning the TLO
discovery e2e (the PR's flagship on-chain proof). Point it at the whole
tests/e2e dir so both node e2e run on dispatch; the RUN_TESTNET_E2E gate
still skips them by default.

Add a concurrency guard to the manual e2e-testnet job — its runs share
fixed testnet ids (USDC/TLO SACs) and must not interleave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…claims

Review findings + migration follow-ups across the docs:

- SDK: bump to 0.3.0; README reframed around the discovery router and
  OnboardStatus (decode the return value rather than treating tx SUCCESS
  as full activation); fix the dangling ARCHITECTURE.md link and the
  buildSponsoredOnboardTx "provided separately" mislabel; testnet example
  (no mainnet router is pinned yet).
- SEP: record the v0.3 discovery-router run under "Proven on testnet" and
  remove the now-false Protocol-26 JS-SDK decode caveat (the JS SDK
  builds, simulates, submits, and decodes the discovery onboard
  end-to-end); drop references to the deleted assetAuthRequired helper
  (capability is discovered on-chain) and list decodeOnboardStatus in the
  integrator surface.
- mark the pre-pivot 2026-06-08 plan doc as SUPERSEDED.
- .env.example: drop the dead PUBLIC_STELLAR_HORIZON_URL and the stale
  pre-pivot PUBLIC_TRUSTLINE_ONBOARD_CONTRACT_ID / PUBLIC_TEST_* blocks.
- discovery.ts doc example: add the SEP-required VERSION field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Preserve the pre-migration Horizon behavior: the connect-time "already
activated?" pre-check must not turn a transient RPC blip into a dead-end
error screen. A missing/unfunded account or a read error now reads as
not-activated, exactly as before; the activate() flow still surfaces real
submit errors, and a misconfigured insecure-http endpoint still throws at
construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The service was renamed (soroban-rpc -> stellar-rpc); the code already
uses the modern `rpc` namespace from @stellar/stellar-sdk (never the
deprecated SorobanRpc alias). Update the lingering comment/doc mentions
for consistency. The testnet endpoint hostname (soroban-testnet.stellar.org)
is unchanged — that is still its official URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- react.tsx: the public SDK hook claimed "Activated" on an undecodable/absent
  OnboardStatus return; now it only claims full activation on a chain-confirmed
  Authorized (unknown -> trustline-only), matching the dApp's truthful guard.
- examples/exchange-withdrawal: migrate both demos off the deleted
  assetAuthRequired + Horizon — demo-open.mjs no longer imports the removed
  export (it crashed at module load), and both pass rpcUrl to the migrated
  getActivationStatus / buildSponsoredOnboardTx; Horizon.Server is kept only as
  the demos' own classic-submission transport.
- docs: the SEP Backend-1 sequence diagram no longer calls the deleted
  assetAuthRequired() (-> discover()); docs/authline-sdk.md drops it and uses
  the 2-arg onboard(sac, holder); the README build example is flagged as a
  standalone testnet illustration.
- ops: drop the now-dead PUBLIC_STELLAR_HORIZON_URL from both deploy workflows
  and fix the environments.toml comment that pointed at a removed env var.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@willemneal willemneal deployed to dapp-pages June 10, 2026 17:01 — with GitHub Actions Active
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.

2 participants