Skip to content

Support fee-exempt Bitget routes on Solana with co-signing - #5117

Open
AryanGodara wants to merge 2 commits into
aryan/be-253-bitget-adapterfrom
aryan/be-254-bitget-cosign
Open

AryanGodara wants to merge 2 commits into
aryan/be-253-bitget-adapterfrom
aryan/be-254-bitget-cosign

Conversation

@AryanGodara

@AryanGodara AryanGodara commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Description

Lets the Bitget engine skip Bitget's 1 bp fee. Bitget only waives the fee on routes it cosigns, so these settlements need a second signature from Bitget. The driver still builds, simulates and signs the settlement. It only collects Bitget's signature before sending.

Changes

  • Engine config takes fee = "standard" (default, 1 bp) or fee = "exempt" (no fee)
  • New engine endpoint POST /cosign. It takes the driver-signed transaction and returns it with Bitget's signature, via Bitget's /signBgwSolTx.
  • When a settlement needs more than one signature, the driver calls the engine's /cosign after simulating and checks the result before sending: same message, its own signature unchanged, every signature valid. A failure keeps the solution for a retry. There's no driver config for this.
  • Signer::sign now signs the fee payer slot and leaves other signers' slots empty.
  • Added a test-only RecordingSender to cow-solana-rpc because the pinned solana-rpc-client mock can't decode v0 transactions.

With fee = "exempt", Bitget's co-sign service becomes a dependency of every Bitget settlement. If it fails, Bitget still wins auctions and each settle fails; switching back to fee = "standard" fixes that.

How to test

  • unit and driver API tests
  • tested on staging

Signed-off-by: Aryan Godara <aryangodara03@gmail.com>
@AryanGodara AryanGodara self-assigned this Oct 16, 2026
@linear-code

linear-code Bot commented Oct 16, 2026

Copy link
Copy Markdown

BE-254

…onse error

Signed-off-by: Aryan Godara <aryangodara03@gmail.com>
@AryanGodara
AryanGodara marked this pull request as ready for review October 19, 2026 09:41
@AryanGodara
AryanGodara requested a review from a team as a code owner October 19, 2026 09:41
@claude

claude Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @AryanGodara's task in 3m 41s —— View job


PR Review: Fee-exempt Bitget routes with co-signing

Reviewed the full diff against origin/aryan/be-253-bitget-adapter. This is a clean, well-structured change with thorough test coverage (unit + driver API + live #[ignore] tests). The co-sign verification (verify_cosigned: same message, unchanged driver signature, matching slot count, every signature valid) is well thought out, and I particularly like the rejects_a_dropped_signature test guarding the "verify pairs up to the shorter list" footgun.

I left two inline comments:

  • solana-driver/.../competition.rs (primary) — the elapsed-deadline guard that protects the send is missing before the cosign call, so an already-expired deadline still fires a wasted, rate-limited request to Bitget. Suggested mirroring the existing is_zero() guard.
  • solana-solvers/.../domain/solver/mod.rs (minor) — cosign bypasses the rate_limiter/concurrency bound that solve uses; low risk given settle frequency, but retry storms on a failing cosign would hit Bitget's /signBgwSolTx unthrottled.

Nothing blocking. The failure mode where Bitget's cosign becomes a hard dependency of every exempt settlement is already called out in the PR description, so I didn't re-flag it.
· aryan/be-254-bitget-cosign

Comment on lines +506 to +513
// Other signers sign the final message, so only now. A failure leaves
// the solution cached for a retry.
let transaction = if transaction.message.header().num_required_signatures > 1 {
let remaining = deadline.saturating_duration_since(Instant::now());
self.solver.cosign(&transaction, remaining).await?
} else {
transaction
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The elapsed-deadline guard that protects the send (the confirm_timeout.is_zero() check just below at line 518-521, with the comment explaining why a zero timeout is handled before touching the network) is missing here before the cosign.

If the deadline has already passed by the time we reach this point (e.g. simulation was slow), remaining is zero and we still issue the /cosign request to Bitget. reqwest does not skip a zero-duration timeout — it either fires the request and times out immediately or, worse, runs the full round trip — so Bitget may sign a transaction that can never be submitted (the send guard below then returns DeadlineExceeded). That's a wasted, rate-limited call to Bitget's co-sign service.

Consider mirroring the guard before cosigning:

let transaction = if transaction.message.header().num_required_signatures > 1 {
    let remaining = deadline.saturating_duration_since(Instant::now());
    if remaining.is_zero() {
        return Err(Error::DeadlineExceeded);
    }
    self.solver.cosign(&transaction, remaining).await?
} else {
    transaction
};

Comment on lines +45 to +52
impl Solver<Dex> {
pub async fn cosign(
&self,
transaction: &VersionedTransaction,
) -> Result<VersionedTransaction, dex::Error> {
self.quoter.cosign(transaction).await
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: cosign calls self.quoter.cosign(..) directly, bypassing the rate_limiter (and the concurrency bound) that solve goes through. Settles are far less frequent than solves, so this is likely fine in practice — but note that when a Bitget cosign starts failing and the driver retries each settle, these retries hit Bitget's /signBgwSolTx unthrottled. Worth a comment at least, or routing cosign through the same limiter if Bitget rate-limits that endpoint.

@squadgazzz squadgazzz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I will do another round a bit later, since the changes touch pretty important mechanisms outside of the bitget solver logic.

&self,
transaction: &VersionedTransaction,
) -> Result<VersionedTransaction, Error> {
self.post::<dto::CosignResponse>(COSIGN_PATH, &dto::CosignRequest::new(transaction))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This goes through the same egress-proxy location as /swap. In nodes/egress-proxy/nginx.conf.template, /bitget/solana/solve has limit_req zone=bitget_solana_solve burst=${BITGET_SOLANA_SOLVE_BURST} nodelay, staging sets 2 r/s with burst 1, and the overflow is a 429. The solve loop runs concurrent-requests = 2 against that same zone, so a cosign that lands next to two quotes gets the 429, maps to RateLimited, and the settle fails as SolverFailed. Neither the engine nor the driver retries it. This needs to be fixed.

Comment on lines +73 to +74
/// Sign the message's fee payer slot, which must be the signer's key. The
/// slots of any other required signers stay empty for them to fill.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With the sole-signer check gone, the driver accepts a multi-signer message from any engine, and the solve-time bundle simulation skips signature checks. So a Jupiter solution with a stray isSigner wins the auction and then fails every settle with a 501 from /cosign. Not new, before this, it died in encode, but it's still only caught after the auction. Would a solve-time check make sense, drop a solution needing a second signature unless the engine serves /cosign?


// Other signers sign the final message, so only now. A failure leaves
// the solution cached for a retry.
let transaction = if transaction.message.header().num_required_signatures > 1 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IIUC, this is the only place that decides a solution needs a cosigner, and it runs after the auction is won. At solve time, simulate_solution already compiles the message (resolved.unsigned()), so the header is known there, and the bundle simulation skips signature checks, so a solution with a second signer passes it. For an engine without /cosign that is a solution that wins and then fails every settle. Probably worth a cosign = true flag on the [[solvers]] entry? Without it, drop a multi-signer solution in simulate_solution as a proven failure. Then Jupiter can never reach this branch, and the flag is a driver-side kill switch next to the engine's fee setting.

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