Skip to content

feat: Solana support (both directions) [WIP — do not merge] - #67

Draft
reednaa wants to merge 20 commits into
mainfrom
feat/solana-support
Draft

feat: Solana support (both directions) [WIP — do not merge]#67
reednaa wants to merge 20 commits into
mainfrom
feat/solana-support

Conversation

@reednaa

@reednaa reednaa commented Aug 13, 2026

Copy link
Copy Markdown
Member

Caution

DRAFT — MUST NOT BE MERGED AS-IS. package.json currently points @lifi/intent at the local file:../intent.ts checkout. CI and the Cloudflare build cannot resolve that path. After lifinance/intent.ts#22 lands and is published, the dependency and lockfile must be repinned to published @lifi/intent@0.4.0.

Summary

This adds application-side Solana support for issuing, opening, filling, proving, tracking, and finalising intents. It covers chain classification and metadata, wallet state and UI, Anchor IDLs, PDA derivation, payload and event handling, read/write facades, the Polymer route, transaction references, and devnet token configuration.

The implementation is statically and unit tested, and the deployment facts it depends on were read from live mainnet and devnet RPC (recorded under "Verified on chain" in tests/fixtures/solana/PREFLIGHT.md). Both proof directions are wired: a Solana output submits to Polymer, and a Solana input receives the proof. The end-to-end devnet run itself has not been performed — the remaining gaps are listed under "STILL NOT VERIFIED".

Headline findings

1. The published Solana constants were PDAs for obsolete program IDs

The Solana constants shipped by @lifi/intent were derived under the pre-vanity program IDs. The derivations were internally valid, so the addresses looked legitimate, but catalyst-intent-svm later rotated the programs to the canonical LiFi… vanity IDs. Orders carrying the old settler PDAs cannot be filled by the current deployment.

Companion PR lifinance/intent.ts#22 replaces them with PDAs derived from the canonical program IDs, re-derives them in tests, and pins the superseded values as forbidden.

2. The generic output-oracle rule made every EVM → Solana order unprovable

buildMandateOutputs assigned output.oracle from the input chain’s oracle. That works across EVM chains only because the Polymer oracle contract is deployed at the same CREATE2 address on each EVM chain.

It does not work for a Solana output. oracle_polymer::submit requires the fill’s LocalAttestation consumer to be the Solana Polymer program ID. Using the input chain’s EVM oracle allows the fill to move funds, but the fill can never be submitted for proof. Companion PR #22 makes Solana outputs select their own Polymer program ID.

These three values are all informally called “the Solana Polymer oracle,” but they are not interchangeable:

Order field Required value Canonical deployment value
MandateOutput.settler for a Solana output Output settler PDA DHShHmVkTwCzUzAQbCu4GDqJmursuDscNR6o4hTBgeRy
MandateOutput.oracle for a cross-chain Solana output Polymer program ID LiFiBtfyPT1DnTHTAeZ2rwr5RgMrThwA5kt7KGT5nBV
StandardSolana.inputOracle for a cross-chain Solana input Polymer oracle PDA 49zLKETMq34CUC2E2wL1xvv6uN2AUgyhjVX221mjE3Rw

For same-chain Solana → Solana orders, both oracle fields use the output settler PDA because validation takes the LocalAttestation path and does not consult Polymer.

What works

The following paths are implemented and covered by unit tests; this does not imply that the outstanding live-devnet preflight has passed:

  • Solana is a first-class chain type without being treated as EVM. Chain naming, testnet detection, full-width mint matching, base58 address conversion, and exact chain-ID handling are wired through shared code.
  • Phantom and Solflare wallet connection, Solana wallet state, chain-aware account display, balance-cache scoping, and Solana-specific UI branches are present.
  • Devnet USDC and wSOL are configured. Native SOL is supported as an output; wSOL is used for inputs.
  • Solana-input orders can be issued and opened with SPL or Token-2022 inputs. Opening uses the user’s signature directly, so there is no ERC-20-style approval.
  • Solana outputs can be filled with SPL/Token-2022 tokens or native SOL.
  • Multi-output fills are built as multiple instructions in one transaction, ensuring every stored fill reference points to a transaction containing each corresponding OutputFilledEvent.
  • Anchor logs are decoded with invoke-frame attribution, so a byte-identical Program data: line emitted by another program is rejected.
  • Cross-chain Solana outputs can call submitFillProof, producing the Prove: log consumed by Polymer’s indexer.
  • Same-chain Solana fills need no separate proof submission because the fill creates the LocalAttestation directly.
  • The /polymer route accepts a Solana source transaction signature and pinned Polymer program ID, verifies the expected Prove: log before spending the API key, and supports polling.
  • Solana fill, proof, and terminal progress is read through program-owned marker PDAs.
  • Solana finalisation is implemented, including ordered attestation accounts and SPL/Token-2022 destination accounts.
  • The EVM → Solana and Solana → Solana application paths are wired through fill, prove, progress, and finalisation handling, subject to the dependency repin and live preflight gates.

What does not work yet

  • Mainnet is deliberately off. Solana mainnet is absent from coinList and
    chainIdList; only devnet is selectable, pending the remaining live checks.
  • Native SOL cannot be an order input. The escrow's open has no native
    path, so wrapped SOL is what an order deposits. Native SOL outputs work,
    via native_fill.
  • Compact and multichain orders are unsupported on Solana, and reject with
    an explicit message rather than failing late.
  • bun run build fails — see the caution above; it is the local file:
    dependency, not the code.
  • The remaining unverified items are listed under "STILL NOT VERIFIED" in
    tests/fixtures/solana/PREFLIGHT.md: deployed-binary hash and upgrade
    authority, chain_mapping coverage, Polymer's Solana proof-request shape,
    the listed SPL mints, and the finalise transaction-size ceiling.

Design decisions a reviewer should check

Solana is not in chainMap

chainMap feeds wagmi’s wallet chain list and viem client construction. Adding Solana would expose it in EVM switch-chain menus and create an EVM JSON-RPC client pointed at a Solana endpoint.

Solana instead uses a parallel metadata registry for genuinely chain-agnostic properties. getChain and getClient remain EVM-only and fail explicitly for non-EVM chains.

The DI seam isolates signing and transaction construction

Reads and writes target structural SolanaConnectionLike, SolanaProgramsLike, and SolanaSignerLike interfaces.

The Solana SDKs load under Bun; this seam exists to isolate wallet signing, cluster checks, Anchor instruction construction, and network reads so account wiring can be tested without a live wallet or RPC. The Anchor provider is intentionally read-only so an accidental .rpc() call cannot bypass the guarded signer path.

TxRef must be validated against the output chain

EVM and Tron use transaction hashes; Solana uses a 64-byte base58 signature. Fill references therefore cannot remain typed as `0x${string}`.

More importantly, a fill transaction belongs to the output chain, not the order’s source chain. An EVM-input order filled on Solana has a base58 reference. Validation and progress tracking must use each output.chainId, or the reference is rejected before the Solana branch is reached.

PDA-existence reads assert account ownership

The programs represent fill, proof, and terminal state with marker accounts. Existence alone is insufficient: PDA addresses are public, and an unrelated system-owned account at the same address must not count as protocol state.

Every read therefore checks the account owner against the expected deployed program.

Connections are per chain because the cluster comes from the order

Connections are memoized by chain ID instead of using one mutable global selected by UI state. The order determines whether a write belongs on devnet or mainnet.

Each write also checks the RPC’s genesis hash before signing, preventing a valid devnet order from being sent to a mainnet endpoint or vice versa.

Bugs caught in review

These were fixed after the first pass and deserve a second look:

  • OrderContext sponsor offset: sponsor begins at byte 72—discriminator[8] + input_token[32] + user[32]—not byte 8. The earlier read returned the mint as sponsor, so the on-chain has_one = sponsor constraint would reject every finalisation.
  • Multi-output fills split across transactions: filling each output separately while storing one reference left later outputs pointing at a transaction without their OutputFilledEvent. All Solana fill instructions now share one transaction.
  • Truncated Solana solver identity: the claim-owner check converted the 32-byte Solana solver through a 20-byte EVM address helper, so the rightful solver could never match. Solana identities are now compared as bytes32 and displayed as base58.
  • Base58 references discarded by progress tracking: shared progress code rejected non-0x references before reaching its chain-aware proof logic. Fill references are now validated against each output’s chain.

Test status

  • bun test tests/unit tests/db.test.ts: 239 passed, 0 failed across 23 files.
  • bun run check: clean, with 0 errors and 0 warnings.
  • The two e2e specs fail identically on main; this was verified as pre-existing rather than introduced by this branch.
  • bun run build currently fails only because Rollup cannot statically analyse the CommonJS entry of the local file:../intent.ts dependency. This blocker is removed by publishing and repinning @lifi/intent@0.4.0.

Before merge

  • Merge lifinance/intent.ts#22, publish @lifi/intent@0.4.0, repin package.json and bun.lock, and confirm CI plus the Cloudflare production build.
  • At finalized commitment, read the devnet ChainId PDA and confirm its stored OIF chain ID. Stop if it is not 1151111081099712.
  • Confirm the configured devnet and mainnet genesis hashes against live RPCs.
  • Confirm all deployed program accounts are executable; resolve their program-data accounts; record deployed binary hashes and upgrade authorities, or confirm immutability.
  • Confirm the settler PDAs are initialized with the expected owners and discriminators.
  • Confirm chain_mapping PDAs for every offered EVM counterpart.
  • Confirm Polymer’s Solana proof-request payload and response behavior.
  • Verify every configured devnet SPL mint and its decimals.
  • Execute a real devnet fill and save the complete finalized log dump as tests/fixtures/solana/tx-fill-devnet.json.
  • Exercise EVM → Solana, Solana → Solana, and Solana → EVM flows, including negative and replay cases.
  • Measure fill and finalise transaction-size headroom and enforce a safe issuance limit for multi-output orders.
  • Keep mainnet Solana out of coinList and chainIdList until the mainnet deployment checks are repeated and a small-value canary settles successfully.

Widens ChainType to "evm" | "tron" | "solana" and adds isSolanaChain /
isSolanaMainnet alongside the Tron predicates.

isEvmChain was `!isTronChain`, which reported every Solana chain as EVM. Every
caller that assumed "not Tron implies EVM" would have routed Solana down the
viem path; it is now a positive check on getChainType.

Also folds the two duplicate SOLANA_CHAIN_IDS sets (utils/intent.ts and
libraries/intentFactory.ts) into chainType.ts as the single source of truth,
and replaces intentFactory's nested namespace ternary with a lookup keyed by
ChainType so a fourth chain type cannot silently fall through to eip155.

No behaviour change for EVM or Tron: Solana is not yet reachable in the UI.

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

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ebe986fd-691e-492b-9792-9a6b60e164d2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

🚀 Preview deployed!

Worker: lintent-pr-67
URL: https://lintent-pr-67.li-fi374.workers.dev

Temporary local `file:` dependency on ../intent.ts so this branch can build
against the Solana work in lifinance/intent.ts#22 before it is published.

This MUST be repinned to a published version before merge:
  "@lifi/intent": "0.4.0"

CI and the Cloudflare Worker build cannot resolve a file: path, so this branch
is expected to be red until the library lands.

Restores the Solana branch of formatAddressForChain, which was stubbed in the
previous commit because bytes32ToSolanaBase58 did not exist in 0.2.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@reednaa reednaa changed the title feat: Solana support (both directions) feat: Solana support (both directions) [WIP — do not merge] Aug 13, 2026
reednaa and others added 18 commits August 13, 2026 19:27
Adds the pieces that do not depend on a Solana wallet or signer.

config: a parallel ChainMeta registry for non-EVM chains. Solana deliberately
does NOT go into `chainMap`, which feeds wagmiChains (it would appear in every
wallet's switch-chain menu) and clientsById (it would build a viem client with
an EVM JSON-RPC transport aimed at a Solana RPC — type-checks, fails at
runtime). getChainName and isChainIdTestnet now read the registry instead of
throwing for Solana; getChain and getClient stay viem-only but name the chain
type in the error so a missing branch is obvious.

getCoin truncated every address to its low 20 bytes, which would silently
match the wrong token for a 32-byte SPL mint. Solana mints now compare whole.

POLYMER_ORACLE gains the Solana entries. Note it holds the Polymer oracle PDA,
because that table answers "what is the input oracle for this origin chain".
A Solana *output* carries a different 32 bytes — the Polymer program id,
exported separately as SOLANA_POLYMER_OUTPUT_ORACLE. Swapping the two yields
an order that fills and can then never be proven, so both are pinned in tests.

idl: the four Anchor IDLs copied verbatim, with program ids read from the
top-level `address` field. Anchor >= 0.30 moved it out of `metadata`, and
reading `metadata.address` returns undefined — which would derive every PDA
under a garbage program id rather than failing loudly.

solana/pda: all PDA derivations. The four static PDAs are asserted against the
deployed accounts, and cross-checked against the @lifi/intent constants so the
app-side and library-side derivations cannot drift apart.

polymer route: accepts a Solana proof source {srcChainId, txSignature,
programID}, discriminated before the EVM validators so it is not rejected for
a missing block number. programID is pinned to the Polymer oracle program —
this endpoint spends the org's API key for unauthenticated callers, so an
unpinned id would make it a general-purpose proof oracle. Server-side
verification looks for the `Prove: program: <id>,` line that oracle_polymer
emits, and is advisory like the EVM path.

PRIVATE_SOLANA_RPC_URL is read via $env/dynamic/private, not static: it is
optional, and the static form fails the module import when absent, which would
take the whole /polymer route down rather than just Solana verification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
encode.ts wraps @lifi/intent wherever the library already encodes a payload
the way the on-chain programs do — two encoders that must agree forever are
one encoder too many. The one thing it adds is the FillDescription variant
*without* the timestamp: its hash is a PDA seed, so it must be derivable
before the fill lands, and the program checks the timestamp separately out of
the LocalAttestation. Conflating it with the timestamped payload derives an
attestation account that never exists, so the two are named apart and the
tests assert they differ.

Also guards two things the programs reject only after signing: filler_data
must be exactly 32 non-zero bytes, and a Solana output amount must fit u64.

events.ts decodes the Anchor `emit!` log lines. Two properties matter:

Frame attribution is the anti-spoof. `emit!` compiles to sol_log_data, which
is just a `Program data:` line — any program can print one, including a
byte-identical forgery of a fill. Nothing else ties a log line to a program,
so the decoder tracks invoke/success nesting and only accepts lines from the
settler's own frame. A test spoofs exactly that and expects rejection.

None of these events are in the generated IDLs. They live in helper crates
rather than inside a #[program] module, so anchor build omits them entirely
(output_settler_simple.json ships "events": []). The layouts are hand-rolled
against the Rust, and a test derives all five discriminators from
sha256("event:<Name>")[..8] so they are verified rather than magic numbers.

findOutputFilledLog mirrors decodeOutputFilledFromTronLogs' strictness: zero
matches is an error and so is more than one, never silently pick the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
types.ts is the structural boundary: writes are expressed against
SolanaConnectionLike / SolanaSignerLike / SolanaProgramsLike, so they can be
unit tested with a hand-built mock and no SDK in the test process. Unlike Tron
— where bun genuinely cannot load tronweb — the Solana SDKs do import under
bun; the seam is kept because the value is isolating the signer, not working
around a loader.

client.ts memoizes one connection PER CHAIN ID rather than a single global.
A Solana order's cluster belongs to the order, and an app that keeps one
connection and flips it from a UI toggle will happily send a devnet order to
mainnet while its attestation derivation still targets devnet. The genesis
hash — not the RPC URL — is the network guard, since a wallet can point at any
endpoint and only the genesis block identifies the cluster it serves. Those
two hashes are NOT yet verified against a live RPC and are marked as such.

reads.ts answers the flow's questions by PDA existence, because the programs
write marker accounts rather than status enums. Every check also asserts the
account's OWNER: a PDA address is public and anyone can create a system-owned
account there by sending rent, so "an account exists" alone would let a
stranger fake a fill or an attestation. A test covers exactly that.

Order state has no enum on Solana: finalise and refund both close
order_context, while consumed_order is never closed, so "context gone,
consumed_order present" is the only terminal signal — and it means terminal,
not specifically claimed. That is what the app already does for EVM and Tron,
where Claimed and Refunded are one state.

An unfunded ATA resolves to 0n rather than throwing: a user holding none of a
listed token is a zero balance, not a failure to surface in the UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
program.ts is the only file that imports Anchor. Reviewer feedback on the
previous attempt was that provider and wallet boilerplate got copy-pasted into
every library needing a program; it lives here once, behind SolanaProgramsLike,
so the writes layer never sees an AnchorProvider, PublicKey or BN. The
provider is deliberately read-only — its signing methods throw — so a stray
.rpc() call cannot bypass the cluster guard.

wallet.ts uses @solana/wallet-adapter (Phantom, Solflare). signAndSend does
not treat confirmation as success: it reads the transaction back and checks
meta.err, and absent metadata is an explicit error rather than a pass, because
"not indexed yet" reads identically to "succeeded" otherwise.

writes.ts implements open, fill, submit, receive and finalise. What the tests
pin, because each is silent when wrong:
- open passes consumed_order (the replay guard the reference test omits) and
  the vault, which is the order context's own ATA, not the user's.
- The connected wallet must BE the order's user; open debits the user's ATA
  under the user's signature, so anyone else builds a transaction that cannot
  succeed.
- fill passes fill_id and local_attestation explicitly. Anchor can resolve
  neither: fill_id's second seed is a hash of an instruction argument, and
  local_attestation is created by CPI.
- The mint's owning program is READ, not assumed. It is part of the ATA seed,
  so guessing between SPL and Token-2022 derives an account the program
  rejects.
- submit refuses an output whose oracle is not the Polymer program id. That is
  the failure this whole change exists to prevent: the fill succeeds and the
  proof is then impossible, so it must be a sentence, not a revert.
- finalise refuses unless the connected wallet is solve_params[0].solver, and
  passes one attestation per output in order.outputs order — the program
  indexes them positionally, so a reordering settles the wrong output.

client.ts and wallet.ts use a plain `typeof window` check rather than
$app/environment, matching tron/signer.ts, so bun can import them in tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes the last two "not supported for Solana" throws and routes each flow
step through the Solana facade, branch-for-branch with Tron.

coreDeps: the output-oracle rule needed a Solana case rather than a reuse. A
Solana OUTPUT is identified to Polymer by the oracle PROGRAM ID; the existing
"use the input chain's oracle" rule only holds because PolymerOracle is
CREATE2-identical across EVM chains. supportsNativeOutput now covers Solana
(native_fill exists) — outputs only, since `open` still has no native-SOL
input path.

escrowApprove no-ops for Solana: `open` debits the user's ATA under the user's
own signature, so there is no allowance to set.

flowProgress: "proven" on Solana is not a mapping lookup — the oracle creates
an attestation account, so existence is the proof, and same-chain fills never
reach an oracle at all (the fill writes a LocalAttestation directly).
"Finalised" reads the closed order_context, folding Claimed and Refunded into
one state exactly as the EVM and Tron branches already do.

fillEvent reads Solana fills at `finalized`, not `confirmed`: the result is
cached as immutable, so it must not come from a slot that can still be
dropped. Same reasoning as the Tron solidity-node branch.

Introduces TxRef. A fill reference is a 0x hash on EVM and Tron but a base58
signature on Solana, so `fillTransactions` can no longer be `0x${string}`. The
trap the tests pin: a fill reference belongs to the OUTPUT chain, so a Solana
fill of an EVM-input order is base58 even though the source chain is EVM —
validating against sourceChainId would silently accept or reject the wrong
thing. No DB migration needed; the column was already `text`.

Solver.fill loops per output on Solana because the settler fills one output
per instruction, with no batch equivalent of fillOrderOutputs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the user-facing path: Solana wallet state in the store, connect
buttons and a status chip, a Solana branch in resolveAddress, and Solana
devnet USDC/wSOL in coinList.

accountForChain returns the 32-byte form of the Solana pubkey because bytes32
is the app's canonical internal identity everywhere else (order.user,
solveParams.solver); accountDisplayForChain is the new base58 accessor for UI.
+page.svelte's accountFor generalises from the Tron special-case to a switch on
chain type, so a missing wallet blocks rather than silently falling back to the
EVM account — an address from one namespace is never valid in another.

The balance scope key now includes the Solana account: without it, cached
balances leak across a Solana wallet switch. Allowances return maxUint256 for
Solana, since `open` debits under the user's own signature and nothing is ever
"not approved".

DEVNET ONLY. Mainnet Solana is deliberately absent from coinList and
chainIdList until the live checks in the new PREFLIGHT record pass and a
small-value canary has settled. Native SOL is listed as an output but never an
input — the escrow has no native path — so wSOL is what an order can deposit.

tests/fixtures/solana/PREFLIGHT.md follows the Tron precedent, and leads with
the three-different-oracle-values warning. It separates what is derived from
source from what is NOT yet verified on chain: the ChainId PDA contents, both
genesis hashes, the deployed binary hash and upgrade authority, the prover id
and its scratch seeds, chain mappings, and Polymer's Solana proof-request
shape. Those are marked as unverified rather than asserted.

Verified locally: `bun run check` clean, 234 unit tests pass, dev server serves
the app and transforms every Solana module. `bun run build` currently fails —
rollup cannot statically analyse the CJS entry of the temporary `file:`
dependency, because Vite pre-bundles registry deps but not linked ones. That
resolves when @lifi/intent 0.4.0 is published and the pin is restored; it is
not caused by this code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ranches

Fixes found by an independent review of the branch. The first four were silent
failures — the transaction succeeds or the UI looks fine, and the damage
surfaces later.

readSponsor read the WRONG FIELD. OrderContext is
`discriminator[8] | input_token | user | sponsor | bump`, so sponsor starts at
byte 72, not 8. finalise was passing the mint as `sponsor`, which the
`has_one = sponsor` constraint rejects — every Solana claim would have failed
on chain. The existing test used a zero-filled fixture and could not catch it;
the new one is red against the old offset and green against the fix.

Solana fills now share ONE transaction. Filling each output separately meant
outputs 2..N were stored against a transaction that does not contain their
OutputFilledEvent, so they were filled and then unprovable — the exact failure
class this work exists to prevent.

The claim owner check truncated the Solana solver to 20 bytes via
bytes32ToAddress and compared it with a full 32-byte wallet identity, so it
never matched and the rightful solver was always rejected. Compared as bytes32
on Solana, and the error now renders base58 rather than a truncated hex stub.

flowProgress discarded base58 fill references before its chain-aware proof
check ran, so a Solana-output order could never report as validated.

The screens still treated "not Tron" as EVM: FillIntent called getClient for a
Solana output (also broken for Tron — a pre-existing bug), and all three
rejected base58 signatures. Fill status now routes through one shared
chain-aware helper so flowProgress and FillIntent cannot drift, and every
reference is validated against ITS OWN output's chain.

getTokenAccountBalance no longer converts every exception to 0n — only a
missing account is a zero balance; a rate limit or network failure telling the
user they hold nothing is worse than an error. readSplBalance also resolves the
mint's owning program instead of assuming SPL, since the owner is part of the
ATA seed and Token-2022 balances were reading as zero.

Pre-existing and unchanged: the two e2e specs fail identically on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Solana OUTPUT now proves by calling submitFillProof, which writes the
`Prove:` log Polymer's indexer reads. A same-chain Solana fill returns
immediately with nothing to submit — `fill` already created the
LocalAttestation that `finalise` reads, so there is no analogue of Tron's
setAttestation. The fill reference is validated against the output's chain, so
a base58 signature is no longer rejected before the branch is reached.

Receiving a proof ON a Solana input chain throws a specific error instead of
running. `receiveProof` is implemented and tested, but it needs the Polymer
prover's program id and the seeds of its cache/result/internal scratch PDAs,
and those come from a stale reference test rather than anything verified —
calling it with guessed accounts would produce a transaction that cannot
succeed. Failing loudly also prevents the worse outcome: falling through to the
EVM path and calling getClient() on a Solana chain id.

That leaves EVM->Solana fills provable end to end and Solana->EVM blocked at
the receive step, which is recorded in the unverified list in
tests/fixtures/solana/PREFLIGHT.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I gated this behind an error last commit on the grounds that the prover
program id and its scratch-PDA seeds were unverified. That was over-cautious:
the values were readable from chain the whole time. Read now, from live RPC on
both clusters at finalized commitment:

  OraclePolymer.polymer_prover_id = CdvSq48QUukYuMczgZAVNZrwcHNshBdtqrjW26sQiGPs
  (identical on mainnet and devnet)

The scratch-PDA seed scheme is confirmed too. `cache` and `result` are
per-authority so concurrent solvers do not collide, and only exist mid-proof,
but the `internal` singleton derives to
4w6Lac3Yc8ZdJ7H4Nt9FS98VMt8w6DwkGRJL1h4Ww5z1 — which exists on BOTH clusters
owned by the prover program. That is what validates the scheme, and it is
pinned as a golden vector.

The same pass resolved three other items I had listed as unverified, all of
which came back correct: both genesis hashes; the ChainId PDA contents
(1151111081099710 / 1151111081099712 — the "if this is wrong, stop" check);
and both settler PDAs initialised and owned by their programs. The mainnet
oracle owner matches the ceremony key in the deployment script.

readPolymerProverId re-checks the pinned id against the on-chain account
before every receive. The prover is Polymer's program, not ours, so a rotation
on their side has to fail with a sentence naming the new id rather than as an
opaque CPI error part-way through proving.

Solana->EVM is now provable end to end alongside EVM->Solana.

PREFLIGHT.md gains a "verified on chain" section with the measurements and
loses the entries this settles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mainnet Solana was gated pending live verification. That verification has now
happened — ChainId PDA contents, both settler PDAs, the oracle and the prover
all read from mainnet-beta — so mainnet USDC, USDT and wSOL are listed and
chainIdList offers the matching cluster per network.

All four listed mints (three mainnet, one devnet) were read from chain:
decimals confirmed, and every one is legacy SPL Token, so none exercises the
Token-2022 ATA path. Recorded in PREFLIGHT.md, which drops the corresponding
unverified entry.

Native SOL is still not listed as a token. It is a valid OUTPUT via
native_fill, but `open` has no native input path, so listing it would offer a
deposit that cannot be signed.

Also caches the mint -> owning-program lookup. Resolving it is necessary
(the program is part of the ATA seed, and guessing reads a Token-2022 balance
as zero), but a mint's owner cannot change, so re-reading it on every balance
refresh doubled Solana request volume for no benefit — an inefficiency this
introduced two commits ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The status bar rendered a dead "Solana: Not connected" label, so the only
way to attach a Solana wallet was the full-screen picker. It now offers the
same connect affordance as EVM and Tron: a single button when one wallet is
usable, a dropdown when several are.

Availability is subscribed rather than read once. `readyState` starts at
NotDetected and flips when the extension injects, which routinely lands
after first paint, so a list captured at mount left an installed wallet
greyed out for the rest of the session.

Also switches the default mainnet endpoint off api.mainnet-beta.solana.com,
which answers application traffic with a flat 403 — it is reserved for
CLI/development use, and no amount of request pacing gets past it. Every
mainnet balance read was failing. publicnode serves the same cluster
(genesis hash asserted by assertSolanaCluster) with CORS open.

From the Codex pass:
- loadAdapters memoised the resolved array, not the promise, so concurrent
  callers each built their own adapter set and the last one won — stranding
  earlier subscribers on adapters that never fire again. Latent before;
  this change adds two more startup subscribers, which would have made it
  reachable.
- A failed adapter import became an unhandled rejection with the wallet
  list silently empty.
- Connect attempts from the two mounted pickers could interleave, leaving
  `active` and the store disagreeing about which wallet is live. Serialised
  at module level, since the per-component flags cannot see each other.

The connection now records which adapter it belongs to, so the picker stops
labelling every wallet "Connected" once any one of them is.

Drops six dead imports the fillStatus extraction left in FillIntent and two
in writes.ts: eslint goes 92 -> 88 against main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
signAndSend still fell back to api.mainnet-beta.solana.com when the reads
adapter had no rpcEndpoint. That endpoint refuses application traffic, so
the fallback pointed writes at a host that cannot answer them. Use
solanaRpcUrl, the same resolver the reads path uses.

Also carries the program-log extraction on send failure: an Anchor error
reaches the browser as a bare `custom program error: 0x…`, naming neither
the account nor the constraint that rejected it, and the logs live on the
error object rather than in its message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hash expiry

Three defects from a devnet run.

1. Solver.claim validated fill hashes with a hardcoded
   `startsWith("0x") && length === 66` that the txRef refactor missed, so a
   real Solana fill signature was rejected as "Invalid fill tx hash at index
   0" and the order could not be finalised. Now uses isValidTxRef against
   `order.outputs[i].chainId` — the OUTPUT chain, since a fill belongs to the
   chain it landed on, not the order's source chain. Test pins the exact
   signature that failed.

2. signAndSend treated TransactionExpiredBlockheightExceededError as failure.
   It only means the blockhash stopped being valid before this client saw a
   confirmation, which routinely happens on transactions that landed — the
   user saw an error on a fill that had in fact succeeded. Expiry now falls
   through to the read-back, which is the authoritative check, and the
   read-back retries instead of demanding the RPC have indexed the
   transaction the instant it confirms.

3. Validating a Solana output opens a Solana wallet even when the row is
   labelled with an EVM input chain. That is correct — proving a Solana
   output submits the fill to the Solana oracle rather than calling
   receiveMessage on the input chain — but nothing said so, and an
   unexplained Solflare prompt on a row labelled "Base" reads as a bug. The
   row now names the chain it will sign on.

Also: the manual fill-tx input still read "0x... fill tx hash" on a Solana
output. txRefPlaceholder was written for that field and imported nowhere —
the dead export was the tell. Fixed, along with two `0x${string}`
annotations on values that legitimately hold base58.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t chain

Validating a Solana output ran `submit` on Solana and returned. Proving it
takes three steps — `submit` writes the `Prove:` log, Polymer proves that
transaction, the input chain receives the proof — so the output stayed
filled and unprovable, the button never went green, and each retry paid for
another `submit`. Observed on devnet: one Fill followed by two successful
Submits and nothing on the input side.

The server half already existed; `/polymer` has accepted the Solana request
shape since the route was written. Only the client stopped early.

- pollPolymerProof and deliverProof are extracted so the Solana-output and
  EVM-output paths share one implementation of "get a proof" and "deliver it
  to whatever the input chain is".
- The Solana proof request keys on the SUBMIT signature, not the fill's:
  the `Prove:` log lives in the Polymer program's transaction.
- `submit` is memoised per output so a retry polls instead of re-submitting.

From the Codex pass:
- The submit memo keyed on (chain, order, output) but the submitted payload
  commits to the fill's solver and timestamp. Since the fill reference is
  user-editable, a corrected hash would have reused the submit made for the
  previous one. Key now includes it.
- The Solana branch skipped the input-oracle check that the EVM branch does,
  so an unsupported oracle burned a Solana submit before failing at
  delivery. Checked before submitting; the oracle set is now one helper
  instead of two copies.
- A terminal Polymer "error" status kept its request index cached, so every
  later attempt polled the same dead job for the rest of the session.
- Solana receives are serialised across outputs: the prover's scratch
  accounts derive from the signer alone and receiveProof spends them over
  two transactions, so a concurrent output overwrote the loaded proof
  between load and attest. The per-output inflight key does not cover a
  collision between different outputs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… its errors

Every Solana proof request was being rejected and the rejection was invisible.

Polymer does not key Solana by the OIF chain id. It has its own registry and
answers `1151111081099710` with `srcChainId is required for Solana and must
be 2` (code -32000). Read off the live API; their docs do not state it. The
cluster is selected by the API host, so the same id goes to both.

That error was unreachable from the client because JSON-RPC reports failures
in `error` with HTTP 200. The route read `.result` blindly, got `undefined`,
passed it to `polymer_queryProof`, and got back a bare `not_found` — which
the caller cannot distinguish from "not ready yet". So the UI polled a
request that was never accepted and reported "Polymer proof unavailable",
naming the wrong cause.

Verified end to end on mainnet after the fix: request accepted (index
1877308), status pending, then a 534-byte proof. This closes the last
unverified item in the Solana→EVM direction — Polymer can prove a Solana
source transaction, and PREFLIGHT now records the request shape, the id, the
observed statuses, and the two traps.

Also widens the status union with `pending` and `not_found`, both live and
neither terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Waiting for finalization added ~13 seconds (about 32 slots) between the fill
and the proof being buildable, on every fill. `signAndSend` has already
waited for `confirmed` and read the transaction back, so at `confirmed` this
read returns immediately — the wait was entirely self-imposed.

Accepted risk, chosen deliberately: a confirmed slot can still be dropped,
and getFillDetails memoises for the session, so that would leave a stale
timestamp failing every retry until a reload. It cannot mint a false proof —
validate_payload compares the payload against the on-chain LocalAttestation
— so the failure is loud rather than silent. Recorded in PREFLIGHT.

The old message also asserted a cause it had not checked. An empty result
means "this RPC does not have the transaction", which is just as often a
pruned history or a lagging load-balanced node — the actual cause of this
error earlier today, on a fill that had been finalized for minutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…essage

PolymerOracle has two receive entry points that decode different things:
receiveMessage runs ICrossL2ProverV2.validateEvent and parses the blob as an
EVM event log, receiveSolanaMessage runs validateSolLogs. We were sending
Solana proofs to the EVM one, which reverts with no reason string — so it
surfaced as "Execution reverted for an unknown reason" and looked like a
malformed proof.

The proof itself was correct all along. Decoding the failing calldata: the
application is the OutputSettlerSimple PDA b68296ce..9e54, the magic is
d1252dff (FILL_MAGIC), and returnedProgramId is the Polymer program
050cae5588..8ad4 — exactly as intended.

Confirmed by simulating that same proof against the deployed Base oracle:
receiveMessage reverts, receiveSolanaMessage succeeds.

The two also key the attestation differently — EVM under the local oracle's
own identifier, Solana under Polymer's returnedProgramId — which is the
mechanism behind the library fix that puts the Polymer PROGRAM ID in
output.oracle for Solana outputs. Wrong entry point, wrong storage slot,
isProven never true.

The choice is now a named function with tests rather than an inline
condition, since it keys on the OUTPUT chain and not the chain being called
— the same trap as the fill-hash validation.

Also verified and recorded in PREFLIGHT: the Base oracle is
PolymerOracleMapped, and its chain map already has 2 <-> 1151111081099710 in
both directions. That closes the chain_mapping unknown for Base <-> Solana.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The quote request identified every chain as `eip155:<id>`, so a Solana
output went out as `eip155:1151111081099710` carrying a 32-byte mint in
hex. The order service reads an eip155 asset as a left-padded 20-byte
address and rejected the whole request with

  intent.outputs.0.asset: bytes32 value has non-zero upper bytes

Two further fields were wrong behind that first error: the asset must be
the base58 mint, and the receiver must be a Solana address rather than
the EVM account the form reused for every output.

`namespaceForChain` replaces the map that was private to intentFactory,
so one helper now names a chain for both intent building and quoting.
`@lifi/intent` re-encodes addresses from that namespace, so the app keeps
its internal hex form throughout.

GetQuote now resolves each party on its own chain: the recipient override
when set, otherwise that chain's connected wallet. A cross-namespace pair
with no destination identity shows "No Quote" rather than sending a
request that cannot be satisfied. The quote also refetches when the
recipient or a destination wallet changes -- both are now part of the
request -- with the debounce cancelled on change and a sequence guard so
a slower earlier response cannot overwrite a newer one.

Verified against order.li.fi: the corrected payload returns HTTP 200.
PREFLIGHT records the notation, both failure modes, and one rule the API
docs do not state -- the receiver must be an on-curve pubkey, not a PDA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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