Skip to content

feat(wallet): discover invest assets via SEP-1 and SEP-6/24 (#V184) - #747

Merged
Miracle656 merged 7 commits into
Miracle656:mainfrom
onajidavid87-web:main
Oct 5, 2026
Merged

Miracle656 merged 7 commits into
Miracle656:mainfrom
onajidavid87-web:main

Conversation

@onajidavid87-web

Copy link
Copy Markdown
Contributor

Summary

Implements the Anchor Directory (V184) to dynamically discover invest assets via SEP-1, enforce strict issuer verification, authenticate via SEP-10 using the user's key, and open hosted SEP-24 deposit/withdraw flows with clean return to the wallet.

Key changes:

  • anchorDirectory.ts: SEP-1 TOML parsing, issuer verification, impersonation detection, and host defense against malicious TOML injections (registerDiscoveredAsset).
  • authenticateSep10: SEP-10 challenge signing with zero user data leakage beyond the challenge signature.
  • AnchorDirectoryModal.tsx: Popup/iframe modal for SEP-24 interactive deposit and withdrawal flows.
  • /invest: Anchor Directory UI page for domain discovery, issuer verification badges, and flow initiation.
  • Unit tests (anchorDirectory.test.ts): 12 new unit tests covering TOML parsing, issuer verification, hostile TOML blocking, and SEP-10 authentication.

Related issue

Closes #V184

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Docs
  • Tests
  • CI / tooling

Component

  • Wallet frontend
  • SDK
  • Contracts
  • Agent

Checklist

  • I have read CONTRIBUTING.md
  • cargo test passes (contracts)
  • npm run typecheck passes (wallet / sdk / agent)
  • npm run build passes (wallet / agent)
  • I added or updated tests where relevant
  • I updated docs / README where relevant

Screenshots / test output

PASS lib/__tests__/anchorDirectory.test.ts (12 tests)
Test Suites: 24 passed, 24 total
Tests:       294 passed, 294 total
Typecheck:   tsc --noEmit (Passed with 0 errors)
Build:       Next.js 16.2.10 production build (36/36 static/dynamic routes prerendered)


Closes #737 

@drips-wave

drips-wave Bot commented Sep 23, 2026

Copy link
Copy Markdown

@onajidavid87-web Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@vercel

vercel Bot commented Sep 23, 2026

Copy link
Copy Markdown

@onajidavid87-web is attempting to deploy a commit to the miracle656's projects Team on Vercel.

A member of the Team first needs to authorize it.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Please don't merge this, and please don't run it against a funded account. There's a lot of real effort here, but as written the SEP-10 path will sign a transaction that moves a user's money.

Blocking — authenticateSep10 signs whatever the anchor sends back

frontend/wallet/lib/anchorDirectory.ts, lines 477-496. The validation is:

const manageDataOps = tx.operations.filter(
  (op): op is Operation.ManageData => op.type === 'manageData',
)
if (manageDataOps.length === 0) {
  throw new Error('SEP-10 challenge must contain at least one manage_data operation')
}
if (!tx.timeBounds) { … }
// …expiry check…
const rebuilt = TransactionBuilder.cloneFrom(tx).build()
rebuilt.sign(signerKeypair)

It checks that at least one manageData op exists. It never checks that every op is manageData. So a challenge of [manageData("… auth"), payment(9999 XLM → attacker)] passes all three checks and gets signed in full. Nothing verifies the source account, the server's signature against the TOML SIGNING_KEY, that the sequence number is 0, or the home domain.

The entry point is a domain the user types into a search box, so any domain a user can be talked into entering is a drain vector.

Blocking — the anchor picks the network

Line 467:

const effectivePassphrase = network_passphrase || networkPassphrase

The remote anchor's response overrides the caller's network. A testnet call can therefore produce a mainnet-valid signature. These two defects compose: that is what turns "signs too much" into "signs a mainnet payment during a testnet flow".

Please use @stellar/stellar-sdk's WebAuth.readChallengeTx rather than hand-rolling this. It does the op, source, signature, sequence and home-domain checks for you, and it's the reason this class of bug shouldn't be written twice.

Blocking — it regresses passkey signing to raw-secret signing

app/invest/page.tsx (lines 48, 86, 101) reads veil_signer_secret out of storage and calls Keypair.fromSecret(...). Main's existing getSep10Jwt signs with the passkey. This also widens C3 ("localStorage S-key") from the August security audit rather than narrowing it.

Blocking — the issuer keys aren't valid Stellar accounts

anchorDirectory.ts adds a second VERIFIED_ASSET_REGISTRY, duplicating lib/assets.ts from the already-merged #745. Two of its three issuer keys fail StrKey.isValidEd25519PublicKey:

INVALID  747 USDC   GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335WFGCCHVTLF2CCZAK27ZQQ625
INVALID  747 EURC   GDHU6WR2KCEVDLWBVRWXZVH2AZ3ZX4BH4AXSSOQNTFQC2V3CQE37K3VC
VALID    real USDC  GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN

The USDC one shares a prefix with Circle's real issuer and then diverges. The practical result is backwards: the genuine Circle USDC would be flagged isImpersonating: true by this PR's own impersonation detector.

Also, matchesTomlAccounts = accounts.length === 0 || … means a TOML that declares no ACCOUNTS marks every currency in it as issuer-verified.

Blocking — iframe and postMessage

  • AnchorDirectoryModal.tsx:80 — sandbox="… allow-top-navigation" lets a hostile anchor navigate the whole wallet away.
  • AnchorDirectoryModal.tsx:25-38 — the message listener has no event.origin check, so any frame can post {type:'sep24_complete'}.

Please drop the unrelated changes

  • sdk/src/core.ts — Horizon?.Server ?? Horizon assigns the whole namespace as the Server class when the optional chain misses. That's silent corruption inside security-critical SDK core to work around a test-mock problem; main fixed the actual cause in 9d3ba04.
  • frontend/wallet/next-env.d.ts is auto-generated and reverts a fix on main.
  • frontend/wallet/public/sw.js is regenerated from your local build, so the tracked precache manifest points at chunk hashes that won't exist in the real deploy.
  • The TextEncoder polyfill in feeBump.test.ts is no longer needed — main fixed that file in 9d3ba04.

Direction

frontend/wallet/lib/sep24.ts already exists on main with signSep10Challenge, discoverAnchorInfo, getSep10Jwt, initiateDeposit/Withdraw — and it already enforces the home-domain check this one is missing. The PR imports from it and then reimplements it alongside, less safely. The right shape is to extend lib/sep24.ts, keep passkey signing, and delete the second registry in favour of lib/assets.ts.

The SEP-6/24 discovery UI is worth keeping and I'd like to see it come back on that base. Happy to talk through the SEP-10 validation if useful.

(Linkage note: the body says both "Closes #V184" — a wave ID, not an issue — and "Closes #737".)

@Miracle656

Copy link
Copy Markdown
Owner

Correction to my review above: I cited commit 9d3ba04 for the feeBump.test.ts fix. That hash is wrong — it doesn't exist. The fix is 818f539 ("fix(ci): stop the fee-bump test dragging the whole SDK into its mock"), and it was only pushed to main just now, after I wrote the review. Apologies for the bad reference.

The substance is unchanged: lib/__tests__/feeBump.test.ts is fixed on main by stubbing the @veil/sdk barrel, so any TextEncoder polyfill or Horizon mock you added to that file can be dropped on rebase.

Two other pre-existing main breakages were also fixed just now, in case they were showing red on your PR through no fault of yours: 47be303 resyncs the mobile lockfile (npm ci had been failing, so the mobile job never ran its tests) and 818f539 covers the wallet suite.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The new commit is Merge branch 'Miracle656:main' into main — the branch is current, which is welcome, but both blockers are untouched. I re-read the file at b58d9c8 rather than going from the diff:

  every-op check            : false
  SIGNING_KEY verified      : false
  sequence-number check     : false
  anchor picks the network  : still there

  467|  const effectivePassphrase = network_passphrase || networkPassphrase
  472|  tx = new Transaction(challengeXdr, effectivePassphrase)
  478|  const manageDataOps = tx.operations.filter(
  479|    (op): op is Operation.ManageData => op.type === 'manageData',
  481|  if (manageDataOps.length === 0) {

So a challenge of

[ manageData("example.com auth"),  payment(9999 XLM → attacker) ]

still satisfies manageDataOps.length === 0 being false, still has timeBounds, still passes the expiry check, and is then cloned and signed in full. And line 467 still lets the remote response choose the network, so a testnet flow can produce a mainnet-valid signature.

The entry point is a domain typed into a search box. I am not going to approve a path where a domain someone can be talked into entering results in a signed payment from their wallet, and I would rather say that plainly than keep it in a list of review notes.

What closes it, concretely:

// every operation, not at least one
if (!tx.operations.every((op) => op.type === 'manageData')) throw …
// the caller's network, never the response's
const tx = new Transaction(challengeXdr, networkPassphrase)
// the anchor actually signed it, with the key its own TOML publishes
if (!Transaction.verifySignature(tx, tomlSigningKey)) throw …
// a challenge is not a real transaction
if (tx.sequence !== '0') throw …
// and the source is the account we asked about

SEP-10 §"Verify the challenge transaction" lists these; the SDK also ships
WebAuth.readChallengeTx, which does the whole set and is what I would reach for
rather than hand-rolling the checks.

Everything else in the PR — the SEP-1 discovery, the TOML parsing, the asset listing — is genuinely useful work and I would like it to land. It is these two functions holding it.

@Miracle656

Copy link
Copy Markdown
Owner

Following up on my review with the why, because re-reading it I think I described the defect without explaining the mechanism, and that is not much help when you are the one who has to fix it.

Why a SEP-10 challenge is normally harmless

The anchor sends a transaction, you sign it, you send it back, you get a session token. That is safe because of one property: a challenge can never be executed. Its sequence number is 0, which the network will never accept, and every operation in it is manageData — scratch key/value writes that move nothing. Signing it proves you hold the key and has no other effect.

Both halves of that matter. Lose either one and a "challenge" becomes a real transaction you just signed.

What the current checks allow

478|  const manageDataOps = tx.operations.filter(op => op.type === 'manageData')
481|  if (manageDataOps.length === 0) throw …
485|  if (!tx.timeBounds) throw …
491|  if (nowSec > maxTime) throw …
495|  const rebuilt = TransactionBuilder.cloneFrom(tx).build()
496|  rebuilt.sign(signerKeypair)

The filter asks whether at least one operation is manageData. It never asks whether all of them are. And nothing looks at the sequence number.

I built the transaction that exploits that and ran it through those exact lines:

sequence                 : 41237612385   <- not 0, so it CAN be submitted
ops                      : manageData, payment
manageDataOps.length===0 : false   -> check passes
has timeBounds           : true    -> check passes
signed                   : 1 signature, sequence 41237612385

A hostile anchor returns [manageData("evil.example auth"), payment(9999 XLM → attacker)], sourced from the user's own account at a real sequence number. Every check passes, and the wallet signs it — producing a valid, submittable payment.

The entry point is the domain in the asset-discovery search box, so it is reachable by anyone who can talk a user into typing a domain.

The second one compounds it

467|  const effectivePassphrase = network_passphrase || networkPassphrase

The anchor's own response decides which network the signature is valid for. So the same exchange, run during a testnet flow, can hand back a mainnet-valid signature.

The fix is smaller than the problem

The SDK already implements the whole of SEP-10's verification, and it is in the version this repo is on. Same hostile transaction, handed to it:

WebAuth.readChallengeTx verdict:
  InvalidChallengeError: The transaction sequence number should be zero

So lines 467-496 can mostly become:

import { WebAuth } from '@stellar/stellar-sdk'

// Our network, never the response's.
const { tx } = WebAuth.readChallengeTx(
  challengeXdr,
  serverAccountId,      // SIGNING_KEY from the anchor's stellar.toml
  networkPassphrase,
  homeDomain,
  webAuthDomain,
)

const rebuilt = TransactionBuilder.cloneFrom(tx).build()
rebuilt.sign(signerKeypair)

That one call checks the sequence is 0, that every operation is manageData, that the source account is the one you asked about, that the anchor really signed it with the key its own stellar.toml publishes, and that the domains match.

If you would rather keep it hand-rolled, it is five checks:

  1. tx.operations.every(op => op.type === 'manageData') — every, not some
  2. tx.sequence === '0'
  3. Transaction.verifySignature(tx, signingKeyFromToml)
  4. source account is the server account from the TOML
  5. delete line 467 and parse with networkPassphrase

One thing in your favour

TransactionBuilder.cloneFrom(tx).build() does preserve sequence 0 — I checked. So a well-formed challenge stays harmless all the way through your code. The hole is entirely that nothing rejects a challenge that was never well-formed in the first place. The signing half is fine; it is the accepting half.

To reproduce any of the above, this is the whole script:

const { Account, Asset, Keypair, Networks, Operation, TransactionBuilder, BASE_FEE, WebAuth } =
  require('@stellar/stellar-sdk')

const attacker = Keypair.random(), victim = Keypair.random()
const tx = new TransactionBuilder(
  new Account(victim.publicKey(), '41237612384'),
  { fee: BASE_FEE, networkPassphrase: Networks.PUBLIC },
)
  .addOperation(Operation.manageData({ name: 'evil.example auth', value: 'x' }))
  .addOperation(Operation.payment({ destination: attacker.publicKey(), asset: Asset.native(), amount: '9999' }))
  .setTimeout(300)
  .build()

console.log(tx.operations.filter(o => o.type === 'manageData').length === 0)  // false -> your check passes
WebAuth.readChallengeTx(tx.toXDR(), victim.publicKey(), Networks.PUBLIC, 'evil.example', 'evil.example')

Everything else in this PR — the SEP-1 discovery, the TOML parsing, the asset listing — is good work and I want it to land. Happy to look again as soon as these two functions are updated, and happy to talk it through if any of the above is unclear.

@gitguardian

gitguardian Bot commented Oct 5, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
37882553 Triggered Generic High Entropy Secret 388052b frontend/wallet/lib/tests/sep45.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Head is now 388052b4, which is Merge branch 'Miracle656:main' into main — the second merge in two days. The branch is current and the code is unchanged; I read frontend/wallet/lib/anchorDirectory.ts at that SHA rather than the diff, and lines 467–481 are byte-identical to what I quoted this morning:

467|  const effectivePassphrase = network_passphrase || networkPassphrase
472|  tx = new Transaction(challengeXdr, effectivePassphrase)
478|  const manageDataOps = tx.operations.filter(
479|    (op): op is Operation.ManageData => op.type === 'manageData',
481|  if (manageDataOps.length === 0) {
...
490|  const rebuilt = TransactionBuilder.cloneFrom(tx).build()
491|  rebuilt.sign(signerKeypair)

I have said the substance twice, so here is the part I had not given you: you do not have to write any of these checks.

The SDK you already depend on ships the whole thing

frontend/wallet/package.json pins @stellar/stellar-sdk ^14.6.1, and I checked what that resolves to in this repo's node_modules:

WebAuth functions: InvalidChallengeError, gatherTxSigners, verifyTxSignedBy,
                   buildChallengeTx, readChallengeTx,
                   verifyChallengeTxSigners, verifyChallengeTxThreshold

readChallengeTx has this signature:

readChallengeTx(
  challengeTx:      string,
  serverAccountID:  string,            // the anchor's SIGNING_KEY, from its own TOML
  networkPassphrase: string,           // yours — not the one in the response
  homeDomains:      string | string[],
  webAuthDomain:    string,
): { tx, clientAccountID, matchedHomeDomain, memo }

Every argument is one of the checks that is missing, and it throws InvalidChallengeError rather than returning a transaction when any of them fails. Swapping new Transaction(...) plus the hand-rolled block for one call closes all four — every-op, signing key, network, and the home-domain/web_auth_domain binding — and deletes code rather than adding it.

One thing to add before you can call it

parseAnchorToml reads WEB_AUTH_ENDPOINT at line 244 but never reads SIGNING_KEY, so DiscoveredAnchorInfo has nowhere to carry it:

homeDomain, transferServerSep24, transferServerSep6,
webAuthEndpoint, kycServer, networkPassphrase, accounts, currencies

Add it beside the WEB_AUTH_ENDPOINT line and put it on the interface. Without the anchor's own published key there is nothing to verify the signature against, which is why the current code cannot do this check even in principle.

While you are there: keep networkPassphrase from the TOML if you like, but use it as a cross-check against the caller's, never as the value passed to the parser. A response that picks the network is how a testnet flow produces a mainnet-valid signature.

If you would rather land the rest now

authenticateSep10 has exactly one call site. Everything else in this PR — parseTomlString, parseAnchorToml, fetchAnchorToml, registerDiscoveredAsset, isValidStellarPublicKey, isValidAssetCode, the registry and its tests — is discovery, needs no authentication, and is the bulk of the 1,405 lines. That half is good work and I would merge it.

So either path is fine by me:

  1. Finish it — readChallengeTx plus the SIGNING_KEY parse, which is realistically an afternoon, and V184 is done.
  2. Split it — drop authenticateSep10 and its call site, land discovery now, SEP-10 as a follow-up PR.

V184's criteria do name SEP-10 explicitly, so option 2 means the issue stays open behind the follow-up. That is a trade you should make deliberately rather than by accident, which is why I am asking rather than deciding it for you.

What I will not do is approve the current state. The entry point is a domain typed into a search box, and the end of it is rebuilt.sign(signerKeypair) over whatever operations that domain chose to send back.

https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

onajidavid87-web and others added 4 commits October 5, 2026 22:17
The challenge was parsed and signed after three checks: that at least one
operation was a manage_data, that timeBounds existed, and that it had not
expired. A challenge of

    [ manageData("example.com auth"), payment(9999 XLM -> attacker) ]

satisfied all three and was then cloned and signed in full, with the user's
real key. The entry point is a domain typed into a search box, so a domain
someone can be talked into entering was a drained wallet.

It now goes through WebAuth.readChallengeTx, which is the SDK's implementation
of SEP-10's own "Verify the challenge transaction" list: every operation is a
manage_data, the source is the server account, the sequence is 0, the time
bounds are current, the transaction is signed by the key the anchor publishes,
and the home domain and web auth domain are the ones we asked for. It throws
rather than returning a transaction. That is less code than the hand-rolled
version, not more.

Three things fall out of it:

- SIGNING_KEY is now parsed from the TOML, because without the anchor's own
  published key there is nothing to check the signature against. An anchor that
  omits it can be browsed but not authenticated with, which is stated rather
  than silently skipped.
- The network comes from the caller and is never taken from the response.
  `network_passphrase || networkPassphrase` let the anchor choose, which is how
  a testnet flow produces a signature that is valid on mainnet. A response that
  names a different network is now refused, in the client and on the page.
- The verified transaction is signed directly instead of
  `cloneFrom(tx).build()`, which produced an unsigned copy and dropped the
  anchor's own signature. SEP-10 requires the challenge back with both, so this
  could not have worked against a real anchor.

Tests cover the attack itself (a spec-correct challenge plus one payment
operation, rejected before anything is posted), a challenge signed by the wrong
key, one issued for another account, a response naming another network, an
anchor with no SIGNING_KEY, and that the anchor's signature survives into what
is posted back.

Also reverted four files unrelated to the feature: a local Next artifact in
next-env.d.ts, minifier churn in public/sw.js, a TextEncoder polyfill in
feeBump.test.ts (the suite passes without it), and `Horizon?.Server ?? Horizon`
in sdk/src/core.ts, whose fallback would hand back the namespace where a
constructor is expected.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
The mobile half of this PR reached for WebAuth.readChallengeTx too, which is
the right call, but handed it values derived from the challenge being checked:

    const expectedHomeDomain = homeDomain || firstKey.slice(0, -5)
    const serverAccountID    = anchorSigningKey || tx.source

Both arguments were optional, and the two live callers — buy.tsx:52 and
withdraw.tsx:151 — passed neither. So on the real paths the expected server
account was the transaction's own source and the expected home domain was read
out of the transaction's own manage_data name. An attacker's self-signed
challenge satisfies both, which means the check passed everything it was meant
to stop, while the tests were green because they passed the key explicitly.

The three arguments are now required and have no fallbacks: an anchor's
published SIGNING_KEY, the domain the TOML was fetched from, and the host of
the WEB_AUTH_ENDPOINT we called. `discoverAnchorInfo` parses SIGNING_KEY and
carries the home domain so callers have real values to pass, and both screens
refuse to authenticate with an anchor that publishes no SIGNING_KEY rather than
signing something they cannot verify. These screens sign with getSignerSecret(),
a real key, not a stub.

Also signs the verified transaction rather than `cloneFrom(tx).build()`, which
dropped the anchor's own signature — the same bug as the web client had.

Tests pin the two that matter: a forged challenge signed by a key of the
attacker's choosing is rejected even though it is internally consistent, and a
missing SIGNING_KEY refuses validation instead of falling back.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Same defect as the mobile half, on the path that deposit and withdraw actually
use. `lib/sep24.ts` called WebAuth.readChallengeTx with:

    const expectedHomeDomain = homeDomain || firstKey.slice(0, -5)
    const serverAccountID    = anchorSigningKey || tx.source

and `getSep10Jwt` took both as optional. The three live callers —
app/withdraw/page.tsx:202 and components/DepositModal.tsx:110 — passed neither,
so the expected server account was the challenge's own source and the expected
home domain came out of its own manage_data name. A self-signed challenge
satisfies both.

What follows that check is a passkey assertion over the challenge's hash, so
the blind-signing exposure is the user's actual passkey, not just a keypair.

discoverAnchorInfo now parses SIGNING_KEY and carries the home domain;
homeDomain, anchorSigningKey and webAuthDomain are required with no fallbacks;
and getSep10Jwt refuses an anchor that publishes no SIGNING_KEY rather than
authenticating against an unverifiable challenge. Both call sites pass real
values.

Tests: omitting the signing key or the home domain now throws instead of
falling back. The existing wrong-SIGNING_KEY, wrong-home-domain, non-zero
sequence, expired and extra-payment cases still pass, and the legacy
tests/sep10.test.ts call sites were updated to the required signature.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
@Miracle656
Miracle656 merged commit e8ebbbf into Miracle656:main Oct 5, 2026
0 of 4 checks passed
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