Skip to content

feat(rust): add support for x402:upto#174

Merged
lgalabru merged 6 commits into
mainfrom
feat/rust-upto
Jun 19, 2026
Merged

feat(rust): add support for x402:upto#174
lgalabru merged 6 commits into
mainfrom
feat/rust-upto

Conversation

@lgalabru

Copy link
Copy Markdown
Collaborator

No description provided.

@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds the x402:upto (usage-based) payment scheme to the Rust SDK. The upto scheme opens a payment channel depositing the authorized ceiling, runs the handler, then settles the metered actual amount and refunds the remainder — enabling LLM-token billing and per-byte pricing in a single HTTP round-trip.

  • New X402Upto server (x402/src/server/upto.rs): full channel lifecycle — 402 challenge with blockhash prefetch, transaction validation before co-signing, channel open broadcast, on-chain state binding, in-flight deduplication via RAII guard, and settle_actual with operator-signed voucher.
  • New client (x402/src/client/upto/): build_upto_payload / build_upto_header build the channel-open transaction and wrap it in the PAYMENT-SIGNATURE envelope; parse_upto_challenge reads the 402 challenge.
  • Gate integration (kit/src/gate.rs): paid_upto_get / paid_upto_post middleware with the Charge extractor for usage reporting; returns 503 on RPC failure rather than an un-actionable 402.
  • Refactored solana-pay-core: canonical parse_units, build_open_payment_channel_tx, random_salt, and DEFAULT_GRACE_PERIOD_SECONDS consolidated from solana-mpp so both protocol crates share one implementation.

Confidence Score: 5/5

Safe to merge. All previously identified security and correctness issues have been resolved; the remaining findings are documentation and defensive-coding suggestions only.

All blocking issues from the previous review cycle are fully addressed with tests. The two remaining notes are a misleading doc comment about transaction being empty for zero-charge and the absence of an early expiry guard in settle_actual. Neither affects the normal payment flow.

No files require special attention. rust/crates/x402/src/server/upto.rs is the most complex new file but is well-tested including SOL-drain and dedup scenarios.

Important Files Changed

Filename Overview
rust/crates/x402/src/server/upto.rs Core new file: X402Upto server handler with full channel lifecycle — 402 challenge, open broadcast + validation, settlement. Previous P0/P1 issues (SOL-drain, payer mismatch, in-flight dedup, blockhash failure) are all addressed. One P2 remains: no expiry guard before on-chain settlement.
rust/crates/x402/src/client/upto/payment.rs New client-side upto payment builder: validates profiles, builds channel open tx (payer-signed, operator-fee-payer), wraps in PAYMENT-SIGNATURE envelope. Logic is sound; parse_upto_challenge correctly finds the upto requirement.
rust/crates/x402/src/protocol/schemes/upto/types.rs Wire types for the upto scheme. Serde shapes, optional fields, and amount parsers look correct. One doc mismatch: UptoSettlementResponse::transaction comment says 'empty string when the charge is 0' but the implementation always returns a real signature.
rust/crates/x402/src/protocol/schemes/upto/verify.rs Pure payload verification: profile, amount, deposit ceiling, time window, and authorized_signer checks. Previous redundant facilitator check removed. Logic is correct.
rust/crates/kit/src/gate.rs Adds paid_upto_get/paid_upto_post gate middleware plus Charge extractor. Mutex poisoning correctly handled. Settlement failure returns 502. 503 returned when challenge RPC is unavailable.
rust/crates/core/src/payment_channels.rs Moved from mpp to core and extended with build_open_payment_channel_tx, random_salt, and DEFAULT_GRACE_PERIOD_SECONDS. CSPRNG-based salt, bincode serialization, and async signing look correct.
rust/crates/mpp/src/client/session.rs Payment-channel session helpers moved here from the deleted client/payment_channels.rs; same logic, now delegates to solana-pay-core builders. Public API preserved via re-exports.
rust/crates/core/src/units.rs Canonical parse_units consolidated from both protocol crates into core. Checked arithmetic, MAX_DECIMALS = 18 cap, strict input validation. Well-tested.

Reviews (6): Last reviewed commit: "fix(x402): reject unsupported upto split..." | Re-trigger Greptile

Comment on lines +157 to +164
settlementHeaders: { [PAYMENT_RESPONSE_HEADER]: encodePaymentResponseHeader(settlement) },
transaction: settlement.transaction,
};
}

#requirements(maxPrice: Price): PaymentRequirements {
const coin = maxPrice.primaryCoin() ?? this.#stablecoins[0] ?? 'USDC';
const mint = resolveStablecoinMint(coin, this.#network);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 extra.facilitatorAddress diverges from spec and Rust wire format

The spec (scheme_upto_svm.md §4.1) defines the required field as facilitator, and the Rust UptoExtra struct serializes it as "facilitator". This adapter emits facilitatorAddress instead. When a TypeScript server sends an upto 402 challenge to a Rust client, the Rust client tries to deserialize UptoExtra.facilitator (no #[serde(default)]), finds no matching key, and fails deserialization — breaking cross-SDK interoperability entirely for the upto scheme. The test confirms the mismatch: it explicitly casts to { facilitatorAddress?: string } rather than checking facilitator.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Renamed facilitatorAddressfacilitator in c48e7fa to match spec §4.1 and the Rust UptoExtra wire field. The TS adapter was then untracked from this Rust-only PR (9d17022), so it no longer ships here; the fix carries forward to the separate TS integration PR.

Comment on lines +61 to +68
payload.valid_after
)));
}
if now > payload.expires_at {
return Err(Error::Other(format!(
"authorization expired (expiresAt {} < now {now})",
payload.expires_at
)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Redundant facilitator check — always passes

requirements here is always built server-side by upto_requirements(), which sets extra.facilitator = self.operator(). So requirements.extra.facilitator != operator is equivalent to self.operator() != self.operator() and can never be true. The check gives a false sense of security and will never fire in production or tests. The meaningful binding is the next check (payload.authorized_signer != operator), which verifies the client actually authorized the correct operator.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in c48e7fa: removed the redundant requirements.extra.facilitator != operator check. The payload.authorized_signer == operator check is the real binding and remains.

reference: open.channel_id.to_string(),
});
req.extensions_mut().insert(charge);
let mut resp = next.run(req).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Mutex poisoning silently drops the charge

Both the settlement read and Charge::charge() use lock().ok() / if let Ok(...), silently treating a poisoned mutex as a missing charge and defaulting to 0. If a handler panics after calling charge(), the middleware will settle for zero base units — a full refund — even though the resource was (partially) consumed. Using unwrap_or_else(|e| e.into_inner()) (the standard Rust "recover from poisoning" idiom) would preserve the charge. Alternatively, tokio::sync::Mutex avoids poisoning entirely.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in c48e7fa: both the post-handler read and Charge::charge() now use unwrap_or_else(|e| e.into_inner()) to recover the charge from a poisoned lock instead of silently settling zero.

Comment on lines +219 to +222
.map_err(|e| Error::Other(format!("settlement serialization failed: {e}")))?;
Ok((
PAYMENT_RESPONSE_HEADER.to_string(),
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, json.as_bytes()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 verify_open re-fetches a fresh blockhash for requirements but the client used a different blockhash

Inside verify_open, upto_requirements() is called, which does a best-effort get_latest_blockhash() and embeds the result in the requirements. The resulting requirements object is then passed to verify_upto_payload. Calling upto_requirements a second time here can produce a requirements object with a different blockhash than the one the client acted on. This is harmless today because verify_upto_payload doesn't compare blockhashes, but it's a subtle inconsistency that could confuse future audit work.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in c48e7fa: upto_requirements is now pure (no RPC). The recent blockhash is fetched only in upto() when building the challenge, so verify_open no longer fetches (or diverges on) a blockhash.

The Rust CI 'format check' step (cargo fmt --check) failed on the upto
scheme + core/subscriptions changes. No logic changes — formatting only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +397 to +418
/// Co-sign the fee-payer (operator) slot of a partially-signed transaction.
async fn cosign_fee_payer(&self, tx: &mut VersionedTransaction) -> Result<(), Error> {
let account_keys = tx.message.static_account_keys();
let idx = account_keys
.iter()
.position(|k| k == &self.operator)
.ok_or_else(|| Error::Other("operator (fee payer) not in open transaction".into()))?;
if idx >= tx.signatures.len() {
return Err(Error::Other(
"operator is not a required signer in the open transaction".into(),
));
}
let msg_data = tx.message.serialize();
let sig_bytes: [u8; 64] = self
.config
.operator_signer
.sign_message(&msg_data)
.await
.map_err(|e| Error::Other(format!("fee payer signing failed: {e}")))?
.into();
tx.signatures[idx] = Signature::from(sig_bytes);
Ok(())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0 security Operator co-signs an unvalidated client transaction — SOL-drain vector

cosign_fee_payer signs whatever message the client sends as long as the operator's pubkey is somewhere in static_account_keys. A malicious client can include SystemProgram::transfer { from: operator, to: attacker, lamports: all } (or any other operator-authorized instruction) alongside — or instead of — the channel-open instruction. The operator key's public key is already advertised in every 402 challenge (extra.facilitator), so the attacker has all the information needed. After the server signs and broadcasts, fetch_channel fails (no valid channel), the server returns a 402, but the operator's SOL has already been transferred.

Before signing, the server must verify the transaction contains only the expected program's open instruction: check that every instruction's program_id equals self.program_id() (the payment-channels program), and that the instruction accounts match the expected payee, mint, operator, and payer derived from the payload. Alternatively, the server should build the open transaction itself rather than accepting an unverified one from the client.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b6b58ee: added validate_open_instruction, called before co-signing. It requires exactly one instruction, on the payment-channels program, with the open discriminator and accounts bound to the expected payer/payee/mint/operator/channel — rejecting any other operator-authorized instruction (e.g. a SystemProgram transfer). Added unit tests including a SOL-drain rejection.

Comment thread rust/crates/x402/src/server/upto.rs Outdated
Comment on lines +161 to +165
let recent_blockhash = self
.rpc
.get_latest_blockhash()
.ok()
.map(|hash| hash.to_string());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Silently broken challenge when blockhash RPC call fails

When get_latest_blockhash() returns an error, recent_blockhash is None and the 402 challenge is returned normally — HTTP 402 is issued with no indication of the missing field. Any Rust client using build_upto_payload (in client/upto/payment.rs) hard-requires extra.recentBlockhash and will immediately fail with "requirement missing extra.recentBlockhash". The client sees a payment failure, not a transient RPC error, and has no way to retry with a self-fetched blockhash.

If the blockhash is missing the server should either return an error (so the caller can surface a retryable failure) or let the client supply its own blockhash by documenting and handling the absent field gracefully in the client. Silently advertising a challenge that the in-SDK client cannot act on breaks the Rust-to-Rust flow whenever the operator's RPC endpoint is momentarily unreachable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b6b58ee: upto() now returns a retryable error when the blockhash RPC call fails, instead of issuing a challenge with no blockhash. The gate surfaces this as a 503 rather than a header-less 402.

- TS (P1): emit `extra.facilitator` not `facilitatorAddress` in the upto
  adapter so it matches the spec (§4.1) and the Rust `UptoExtra` wire field —
  fixes cross-SDK deserialization (Rust client would reject the challenge).
- rust verify.rs (P2): drop the redundant `requirements.extra.facilitator !=
  operator` check (always built server-side; can never fire). The
  authorized_signer == operator check is the real binding.
- rust gate.rs (P2): recover from mutex poisoning in `Charge` so a handler that
  panics after `charge()` still settles the consumed amount instead of a silent
  zero refund.
- rust server/upto.rs (P2): make `upto_requirements` pure (no RPC); fetch the
  recent blockhash only in `upto()` when building the challenge, so `verify_open`
  no longer fetches a divergent blockhash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +162 to +174
#requirements(maxPrice: Price): PaymentRequirements {
const coin = maxPrice.primaryCoin() ?? this.#stablecoins[0] ?? 'USDC';
const mint = resolveStablecoinMint(coin, this.#network);
if (!mint) throw new ConfigurationError(`No ${coin} mint known for ${this.#network}.`);
return {
amount: maxPrice.baseUnits().toString(),
asset: mint,
extra: { facilitator: this.#operator, feePayer: this.#operator },
maxTimeoutSeconds: MAX_TIMEOUT_SECONDS,
network: this.#network,
payTo: this.#recipient,
scheme: 'upto',
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Missing profiles in extra breaks Rust client interoperability

The Rust client's build_upto_payload requires requirements.extra.profiles to contain "payment-channel" before it builds the open transaction. UptoExtra has #[serde(default)] on profiles, so when the field is absent it deserializes to an empty Vec. The Rust check requirements.extra.profiles.iter().any(|p| p == PROFILE_PAYMENT_CHANNEL) then fails unconditionally, returning "requirement does not advertise the payment-channel profile". Any Rust client receiving a challenge from this TypeScript server cannot proceed with the upto flow at all. The spec (§4.1) also marks profiles as required (). The fix is to add profiles: ['payment-channel'] to the returned extra.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The TS adapter was untracked from this Rust-only PR (9d17022), so it no longer ships here. profiles will be included in extra when the TS integration lands in its own PR.

Comment on lines +263 to +321
let channel_id = Pubkey::from_str(&payload.channel_id)
.map_err(|e| Error::Other(format!("invalid channelId: {e}")))?;
let payer = Pubkey::from_str(&payload.from)
.map_err(|e| Error::Other(format!("invalid payer: {e}")))?;
let max = payload.max_amount()?;

// Broadcast the client-signed open (pull). Push (already broadcast) is
// not yet supported; require the transaction.
let open_tx_b64 = payload.open_transaction.as_deref().ok_or_else(|| {
Error::Other("payment-channel profile requires openTransaction (pull)".to_string())
})?;
let mut tx = decode_transaction(open_tx_b64)?;
self.cosign_fee_payer(&mut tx).await?;
self.rpc
.send_and_confirm_transaction(&tx)
.map_err(|e| Error::Rpc(format!("open broadcast failed: {e}")))?;

// Read the confirmed channel state and bind it.
let channel = self.fetch_channel(&channel_id)?;
if channel.status != CHANNEL_STATUS_OPEN {
return Err(Error::Other(
"channel is not open after broadcast".to_string(),
));
}
if pc::from_address(&channel.mint) != expected_mint {
return Err(Error::MintMismatch {
expected: pc::pubkey_string(&expected_mint),
actual: pc::pubkey_string(&pc::from_address(&channel.mint)),
});
}
if pc::from_address(&channel.payee) != expected_payee {
return Err(Error::RecipientMismatch {
expected: pc::pubkey_string(&expected_payee),
actual: pc::pubkey_string(&pc::from_address(&channel.payee)),
});
}
if pc::from_address(&channel.authorized_signer) != self.operator {
return Err(Error::Other(
"channel authorized_signer is not the operator".to_string(),
));
}
if channel.deposit < max {
return Err(Error::Other(format!(
"on-chain deposit {} below authorized maximum {max}",
channel.deposit
)));
}

Ok(VerifiedUptoOpen {
channel_id,
payer,
mint: expected_mint,
token_program: self.token_program()?,
program_id,
deposit: channel.deposit,
max_amount: max,
expires_at: payload.expires_at,
network: requirements.network,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Missing channel.payer binding check causes settlement to fail on-chain

verify_open fetches the confirmed channel state and checks mint, payee, authorized_signer, and deposit — but never asserts pc::from_address(&channel.payer) == payer (where payer is parsed from payload.from). VerifiedUptoOpen.payer is set from payload.from, not from the on-chain channel.payer. When settle_actual calls build_distribute_instruction(&open.channel_id, &open.payer, ...), the instruction accounts include the wrong payer pubkey. The payment-channels program enforces that the payer account matches channel.payer for the refund transfer, so the settlement transaction will fail on-chain even though the handler has already run and served the resource. The channel stays open until the grace period expires and the real payer reclaims it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b6b58ee: verify_open now asserts on-chain channel.payer == payload.from after fetching the channel, so settlement distribute cannot fail on-chain due to a payer mismatch.

The pay-kit TS x402/upto adapters belong to a separate, in-progress TS
integration (they import the unpublished @x402 packages). Remove them from
this Rust-only PR; the files stay on disk for that follow-up work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +248 to +321
pub async fn verify_open(
&self,
header: &str,
max_amount: &str,
) -> Result<VerifiedUptoOpen, Error> {
let envelope = self.parse_payment_signature(header)?;
let requirements = self.upto_requirements(max_amount)?;
let payload = &envelope.payload;

verify_upto_payload(payload, &requirements, &self.operator(), now_unix())?;

let program_id = self.program_id()?;
let expected_mint = self.mint()?;
let expected_payee = Pubkey::from_str(&self.config.recipient)
.map_err(|e| Error::Other(format!("invalid recipient: {e}")))?;
let channel_id = Pubkey::from_str(&payload.channel_id)
.map_err(|e| Error::Other(format!("invalid channelId: {e}")))?;
let payer = Pubkey::from_str(&payload.from)
.map_err(|e| Error::Other(format!("invalid payer: {e}")))?;
let max = payload.max_amount()?;

// Broadcast the client-signed open (pull). Push (already broadcast) is
// not yet supported; require the transaction.
let open_tx_b64 = payload.open_transaction.as_deref().ok_or_else(|| {
Error::Other("payment-channel profile requires openTransaction (pull)".to_string())
})?;
let mut tx = decode_transaction(open_tx_b64)?;
self.cosign_fee_payer(&mut tx).await?;
self.rpc
.send_and_confirm_transaction(&tx)
.map_err(|e| Error::Rpc(format!("open broadcast failed: {e}")))?;

// Read the confirmed channel state and bind it.
let channel = self.fetch_channel(&channel_id)?;
if channel.status != CHANNEL_STATUS_OPEN {
return Err(Error::Other(
"channel is not open after broadcast".to_string(),
));
}
if pc::from_address(&channel.mint) != expected_mint {
return Err(Error::MintMismatch {
expected: pc::pubkey_string(&expected_mint),
actual: pc::pubkey_string(&pc::from_address(&channel.mint)),
});
}
if pc::from_address(&channel.payee) != expected_payee {
return Err(Error::RecipientMismatch {
expected: pc::pubkey_string(&expected_payee),
actual: pc::pubkey_string(&pc::from_address(&channel.payee)),
});
}
if pc::from_address(&channel.authorized_signer) != self.operator {
return Err(Error::Other(
"channel authorized_signer is not the operator".to_string(),
));
}
if channel.deposit < max {
return Err(Error::Other(format!(
"on-chain deposit {} below authorized maximum {max}",
channel.deposit
)));
}

Ok(VerifiedUptoOpen {
channel_id,
payer,
mint: expected_mint,
token_program: self.token_program()?,
program_id,
deposit: channel.deposit,
max_amount: max,
expires_at: payload.expires_at,
network: requirements.network,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security No in-flight deduplication — same channel can be served twice concurrently

verify_open has no memory of channel IDs that are currently being processed. If two concurrent requests arrive with the same PAYMENT-SIGNATURE header before the first request's settle_actual finalizes the channel, both calls to send_and_confirm_transaction may succeed (Solana RPC is typically idempotent for already-confirmed transactions), both calls to fetch_channel see CHANNEL_STATUS_OPEN, and both handlers run and serve the resource for a single payment.

An attacker can deliberately replay the same header in parallel to get two (or more) LLM-token generations for one channel deposit. The window of exploitation equals the handler execution time — exactly the slow, expensive case upto is designed for. An Arc<Mutex<HashSet<Pubkey>>> (or DashMap) tracking in-flight channel IDs in X402Upto, acquired before broadcasting and released after settlement, would close this window.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b6b58ee: added an in-flight channel set on X402Upto plus an RAII guard held by VerifiedUptoOpen. A concurrent replay of the same channel is rejected, and the slot is released on drop (after settlement, on early-return errors, or on a handler panic).

- server/upto.rs (P0, security): validate the client open transaction before the
  operator co-signs as fee payer — exactly one instruction, on the
  payment-channels program, with the open discriminator and accounts bound to the
  expected payer/payee/mint/operator/channel. Closes the SOL-drain vector where a
  malicious client could get the operator to sign an arbitrary (e.g. SystemProgram
  transfer) instruction. Added unit tests.
- server/upto.rs (P1, security): in-flight channel dedup via an RAII guard, so the
  same authorization replayed concurrently can't be served twice for one deposit.
- server/upto.rs (P1): bind on-chain channel.payer == payload.from in verify_open,
  so settlement's distribute can't fail on-chain after the resource was served.
- server/upto.rs (P1): upto() now fails (retryable) if the blockhash RPC errors
  instead of issuing a 402 with no blockhash the in-SDK client can't act on.
- kit/gate.rs: surface that as a retryable 503 (not a header-less 402); test
  updated accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lgalabru lgalabru requested a review from EfeDurmaz16 June 18, 2026 22:48
lgalabru added a commit that referenced this pull request Jun 19, 2026
…ardened x402

Mirrors the cross-implementation review on the Rust upto PR (#174) on the TS side:

- interop: the x402-upto adapter now emits the spec `extra.facilitator` (was the
  non-spec `facilitatorAddress`) and `profiles: ['payment-channel']`, so a Rust/
  other-SDK upto client can act on a pay-kit-issued 402. Offer-building reads
  `facilitator` too.
- concurrent double-serve: `requireUsage` now dedups in-flight upto channel IDs
  (acquired after verify, released when settle completes), so a replayed
  PAYMENT-SIGNATURE can't serve the metered resource twice for one deposit.
- re-pin the external/x402 submodule + vendored tarballs to the hardened fork
  commit (channel-open co-sign allowlist + ALT reject + channel.payer binding +
  the same facilitator/profiles rename).
.map_err(|e| Error::Other(format!("invalid recipient: {e}")))?,
&pc::treasury_owner(),
&open.mint,
&[],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this intentionally limited to empty-recipient channels? The draft SVM upto spec says verification should bind channel.distribution_hash to payTo/splits, but verify_open does not check it and settle_actual always distributes with an empty recipient list. If splits are out of scope, should we enforce the empty distribution hash before serving?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Addressed by enforcing the empty-recipient distribution hash before serving, since this first slice does not advertise split recipients.

@lgalabru lgalabru merged commit 7433020 into main Jun 19, 2026
30 of 31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants