feat: Solana support (both directions) [WIP — do not merge] - #67
Draft
reednaa wants to merge 20 commits into
Draft
feat: Solana support (both directions) [WIP — do not merge]#67reednaa wants to merge 20 commits into
reednaa wants to merge 20 commits into
Conversation
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>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
|
🚀 Preview deployed! Worker: |
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Caution
DRAFT — MUST NOT BE MERGED AS-IS.
package.jsoncurrently points@lifi/intentat the localfile:../intent.tscheckout. 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/intentwere derived under the pre-vanity program IDs. The derivations were internally valid, so the addresses looked legitimate, butcatalyst-intent-svmlater rotated the programs to the canonicalLiFi…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
buildMandateOutputsassignedoutput.oraclefrom 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::submitrequires 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:
MandateOutput.settlerfor a Solana outputDHShHmVkTwCzUzAQbCu4GDqJmursuDscNR6o4hTBgeRyMandateOutput.oraclefor a cross-chain Solana outputLiFiBtfyPT1DnTHTAeZ2rwr5RgMrThwA5kt7KGT5nBVStandardSolana.inputOraclefor a cross-chain Solana input49zLKETMq34CUC2E2wL1xvv6uN2AUgyhjVX221mjE3RwFor 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:
OutputFilledEvent.Program data:line emitted by another program is rejected.submitFillProof, producing theProve:log consumed by Polymer’s indexer./polymerroute accepts a Solana source transaction signature and pinned Polymer program ID, verifies the expectedProve:log before spending the API key, and supports polling.What does not work yet
coinListandchainIdList; only devnet is selectable, pending the remaining live checks.openhas no nativepath, so wrapped SOL is what an order deposits. Native SOL outputs work,
via
native_fill.an explicit message rather than failing late.
bun run buildfails — see the caution above; it is the localfile:dependency, not the code.
tests/fixtures/solana/PREFLIGHT.md: deployed-binary hash and upgradeauthority,
chain_mappingcoverage, Polymer's Solana proof-request shape,the listed SPL mints, and the
finalisetransaction-size ceiling.Design decisions a reviewer should check
Solana is not in
chainMapchainMapfeeds 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.
getChainandgetClientremain EVM-only and fail explicitly for non-EVM chains.The DI seam isolates signing and transaction construction
Reads and writes target structural
SolanaConnectionLike,SolanaProgramsLike, andSolanaSignerLikeinterfaces.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.TxRefmust be validated against the output chainEVM 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:
sponsorbegins at byte 72—discriminator[8] + input_token[32] + user[32]—not byte 8. The earlier read returned the mint as sponsor, so the on-chainhas_one = sponsorconstraint would reject every finalisation.OutputFilledEvent. All Solana fill instructions now share one transaction.0xreferences 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.main; this was verified as pre-existing rather than introduced by this branch.bun run buildcurrently fails only because Rollup cannot statically analyse the CommonJS entry of the localfile:../intent.tsdependency. This blocker is removed by publishing and repinning@lifi/intent@0.4.0.Before merge
@lifi/intent@0.4.0, repinpackage.jsonandbun.lock, and confirm CI plus the Cloudflare production build.finalizedcommitment, read the devnetChainIdPDA and confirm its stored OIF chain ID. Stop if it is not1151111081099712.chain_mappingPDAs for every offered EVM counterpart.tests/fixtures/solana/tx-fill-devnet.json.coinListandchainIdListuntil the mainnet deployment checks are repeated and a small-value canary settles successfully.