Skip to content

feat(blockrun): add isolated Solana payment support - #690

Open
jjcc123312 wants to merge 2 commits into
mainfrom
codex/blockrun-solana-payment
Open

feat(blockrun): add isolated Solana payment support#690
jjcc123312 wants to merge 2 commits into
mainfrom
codex/blockrun-solana-payment

Conversation

@jjcc123312

@jjcc123312 jjcc123312 commented Aug 11, 2026

Copy link
Copy Markdown

背景与问题

BlockRun Go SDK 已增加 Solana 支付能力。生产环境同时运行多个 Base 链 Type 100 BlockRun 渠道,因此本次变更的硬约束是:

  • 仅适配 100 / BlockRun
  • Solana 仅开放 /v1/chat/completions/v1/messages/v1/responses
  • 图片、101 / BlockRunVideo102 / BlockRunSeedance 不进入 Solana 路径。
  • 旧记录零迁移;缺失 chain 时继续走 Base。
  • SDK 升级和错误处理不得破坏现有 Base,或因重签/retry/failover 造成重复扣款。

BlockRun 作者确认:结算只对同一签名/nonce 幂等,不按 task/job 去重。不同签名代表不同 nonce,可能分别结算。因此 signed attempt 之后,new-api 必须禁止内部重签、重试和换渠道。

根因与方案

显式支付链

渠道 settings 新增:

  • blockrun_payment_chain
  • blockrun_max_payment_atomic

缺失或空 chain 固定解析为 Base,不根据 URL、私钥或 402 内容猜链。Solana 必须新建独立 Type 100 渠道;已有记录禁止 Base ↔ Solana 直接切换,也禁止进入或离开 Type 100,从而封堵 100(Base) → 101 → 100(Solana) 绕过。

Base 兼容

基础 SDK 升级为 blockrun-llm-go v0.19.5,未引入 VIP。v0.19.5 会向 Base envelope 加入未签名的 builder-code extension。兼容层继续委托 SDK 完成签名,仅在 envelope 层恢复 402 中的原始 extensions。Type 100 Base 和 Type 102 共享该入口,不复制或修改密码学实现。

Solana 信任边界

Solana signer 只接受:

  • scheme=exact
  • network=solana
  • 官方 Solana USDC mint、API URL 和 RPC
  • 合法 payTo、feePayer、wallet
  • 正十进制 amount 与必配 atomic cap
  • maxTimeoutSeconds <= 300

混合 accepts 会显式选 Solana;多个不一致 Solana option 会拒绝。

signed attempt 资金安全

签名成功后、发送 signed HTTP 请求前写入请求级 payment state。此后禁止 retry、failover、second signature 和 redirect;不触发 auto-ban/cooldown;第二次 402 丢弃原始 body;不确定失败归一化为 blockrun_settlement_unknown;对账只保留 SHA-256 前缀和 request ID。

该状态只保护同一次 Gin 请求,不宣称跨节点或跨独立 POST 幂等。

变更范围

  • SDK、Base envelope 兼容层、独立 Solana signer。
  • Type 100 adaptor、支付 Header、redirect 边界。
  • retry/failover、错误归一化、处罚和候选过滤。
  • 渠道创建/更新校验及支付链不可变规则。
  • web/default Type 100 链、URL、wallet、cap 表单。
  • en/zh/fr/ru/ja/vi/es/pt 八语言。
  • 后端及前端回归测试。

明确不包含:图片 Solana、101/102 Solana、classic、website、数据库迁移、跨请求分布式幂等、Terraform、Cloudflare。

验证证据

通过:

  • go test ./relay/channel/blockrun ./relay/channel -count=1
  • controller BlockRun retry/error/penalty/chain-transition 定向测试
  • service BlockRun Solana endpoint filtering 定向测试
  • go test ./types ./relay/common ./dto -count=1
  • go build ./relay/channel/blockrun/...
  • changed-package go vet
  • frontend channel-form tests:13/13
  • bun run typecheck
  • bun run build:check
  • changed-file ESLint
  • bun run i18n:sync
  • git diff --check
  • GitNexus final detect_changes:HIGH,影响流程与预期的 relay、UpdateChannel、ChannelMutateDrawer 一致
  • 独立 code-reviewer:APPROVE
  • 独立 architect:CLEAR

完整 go test ./relay/... 仍被仓库既有、与本次无关的 Claude 文件断言和 Codex image nil-info panic 阻断;本次相关包以及 BlockRunVideo/BlockRunSeedance 定向测试通过。

风险与边界

  • 请求级 payment state 不能阻止客户端/LB 重新发起独立 POST。
  • 调用方收到 blockrun_settlement_unknown 后不得自动重新提交。
  • 32-byte Solana seed 在前端做 Base58/长度校验,payer 由后端 SDK 保存时派生验证;64-byte keypair 可在前端展示 payer。
  • 自动化测试不能替代真实低金额链上结算。

部署建议

  • Router deploy: required
  • Reason: relay adaptor、共享 HTTP transport、retry/failover、渠道选择、错误/处罚逻辑和 SDK 发生变化。
  • Other deploy targets: newapi-console required;staging required;newapi-web、Terraform、Cloudflare not required。

发布顺序:

  1. staging 用独立低余额 Base 钱包回归 Type 100 chat/messages/responses,包括流式、非流式、连续请求。
  2. 回归 Type 102 submit/poll。
  3. 新建独立低余额 Solana Type 100 渠道,验证三个接口、cap、timeout/5xx 不重试及链上对账。
  4. 生产部署代码时保持全部 Solana 渠道禁用。
  5. 现有 Base 稳定后,仅启用一个独立 Solana 渠道灰度。

任何 Base 或 Type 102 回归异常均阻断生产启用。

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 commit 5890cc90 · 共 6 条

relay/channel/api_request.go

  • L560-565: [严重] 这里覆盖了原有 http.Client.CheckRedirect,导致 BlockRun 未签名请求的同源重定向不再执行原客户端的重定向校验(包括最多 10 次重定向限制/SSRF 防护策略)。如果上游返回同源循环重定向,在 RelayTimeout 为 0 或较大时可能长时间占用连接与 goroutine,造成资源耗尽风险。建议在自定义同源/签名判断通过后继续调用原 client.CheckRedirect;若原值为 nil,也需要保留默认的 10 次重定向上限。
originalCheckRedirect := client.CheckRedirect
	clientCopy.CheckRedirect = func(next *http.Request, via []*http.Request) error {
		if signed || !sameHTTPOrigin(origin, next.URL) {
			return http.ErrUseLastResponse
		}
		if originalCheckRedirect != nil {
			return originalCheckRedirect(next, via)
		}
		if len(via) >= 10 {
			return errors.New("stopped after 10 redirects")
		}
		return nil
	}

relay/channel/blockrun/solana_x402.go

  • L30-32: [严重] 这里直接使用未清理的 privateKey 做运行时验签和签名,但渠道保存校验侧会对 key 做 TrimSpace 后判断合法;如果配置中带有前后空白,保存时会通过,实际请求时却会被判定为无效钱包,导致 Solana 支付通道不可用。建议在进入 SDK 前统一 trim,并使用清理后的值创建 payload(同时补充 strings import)。
privateKey = strings.TrimSpace(privateKey)
	if _, err := blockrunSDK.GetSolanaPublicKey(privateKey); err != nil {
		return "", fmt.Errorf("blockrun: Solana wallet key is invalid")
	}

	resourceURL := payReq.Resource.URL

controller/relay.go

  • L515-525: [严重] 这里会把签名后产生的任意上游错误统一替换为 blockrun_settlement_unknown,随后 processChannelError 会通过 shouldApplyChannelPenalty 对该类错误跳过 cooldown/自动禁用。这样如果原始错误是渠道级故障(如 401/403/429/5xx、连接异常等),健康检查信号会被隐藏,异常渠道可能持续被选中并影响后续请求。建议保留原始错误用于渠道惩罚判断(例如在 context 中保存原始错误或给归一化错误携带 cause),仅对明确的支付拒绝/结算不确定且不能归因于渠道健康的问题跳过惩罚;对于原始限流、鉴权失败和 5xx 等仍应按原规则处理。
// 保留原始 err 供渠道健康判断使用;不要仅依赖归一化后的 BlockRun 错误决定是否跳过渠道惩罚。
	relaycommon.UpdateBlockRunPaymentOutcome(c, relaycommon.BlockRunPaymentOutcomeSettlementUnknown, streamTruncated)
	statusCode := err.StatusCode
	if statusCode < 400 || statusCode > 599 {
		statusCode = http.StatusBadGateway
	}
	return types.NewErrorWithStatusCode(
		errors.New("BlockRun signed payment settlement is unknown and may have been charged; automatic retry is disabled, reconcile using the request ID"),
		types.ErrorCodeBlockRunSettlementUnknown,
		statusCode,
		types.ErrOptionWithSkipRetry(),
	)

relay/channel/blockrun/adaptor.go

  • L400: [严重] 这里把所有 BlockRun 支付归一化错误都排除在渠道惩罚之外,但 normalizeBlockRunPaymentError 对签名后出现的任意上游错误(例如 429/5xx/认证失败)都会替换成 blockrun_settlement_unknown。这样原始的限流或渠道故障会被隐藏,既不会进入并发 cooldown,也不会触发自动禁用,异常渠道可能持续被选中并放大失败。建议只对明确的 402 拒付跳过惩罚;结算未知场景应保留原始错误分类/状态用于渠道健康判断,至少对 429、5xx 等继续执行 cooldown/disable 逻辑。
func shouldApplyChannelPenalty(err *types.NewAPIError) bool {
	if err == nil {
		return false
	}
	// 只有明确的支付拒绝不应惩罚渠道;结算未知仍可能由上游 429/5xx 等渠道故障触发,
	// 需要保留原有渠道健康处理,避免故障渠道持续被选中。
	return err.GetErrorCode() != types.ErrorCodeBlockRunPaymentRejected
}

controller/channel.go

  • L0: [严重] 这里在支付错误归一化后只根据归一化后的 err 判断是否惩罚渠道;而 normalizeBlockRunPaymentError 会把所有已签名后的上游错误(包括 401/403/429/5xx 或渠道级故障)都替换成 BlockRun 支付错误,导致 shouldApplyChannelPenalty 返回 false,从而跳过并发冷却和自动禁用。若某个 BlockRun 渠道持续返回 5xx/限流/鉴权失败,系统会持续选中该异常渠道,可能造成连续失败甚至更多已签名请求处于结算未知。建议保留原始错误用于渠道健康判断,仅对明确的支付拒绝/结算未知且无法归因渠道故障的场景跳过惩罚。
penaltyErr := err
	applyPenalty := shouldApplyChannelPenalty(penaltyErr)
	if applyPenalty && shouldMarkChannelConcurrencyCooldown(penaltyErr) {

web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx

  • L630-640: [严重] 当切换到 BlockRun Solana 且表单里已有非空 base_url(例如先选过其他渠道或 Base 自定义 URL)时,这里不会覆盖为 Solana 官方地址;但下方 Solana URL 输入框又是只读,用户无法修正,最终会被校验拦截导致无法创建/保存 Solana 渠道。建议在 Solana 链下始终强制同步为 BLOCKRUN_SOLANA_API_URL,Base 链再仅在空值时填默认值。
if (currentType === 100) {
      const currentBaseUrlValue = form.getValues('base_url')
      const nextBaseUrl =
        blockRunPaymentChain === 'solana'
          ? BLOCKRUN_SOLANA_API_URL
          : currentBaseUrlValue || BLOCKRUN_BASE_API_URL
      if (currentBaseUrlValue !== nextBaseUrl) {
        form.setValue('base_url', nextBaseUrl)
      }
    }

@jjcc123312

Copy link
Copy Markdown
Author

已处理并复核 原 review 评论(评审 commit 5890cc90),修复提交:c7233611e

L560-565:覆盖原有 http.Client.CheckRedirect

已修复。Type 100 的请求级 redirect 策略现在先拒绝 signed redirect 和跨源 redirect,再委托原 client 的 CheckRedirect;原值为 nil 时显式保留 Go 默认的 10 次 redirect 上限。补充了原 hook 委托、nil hook 10 次上限、signed 全状态码拒绝和跨源拒绝回归。

L30-32:Solana private key 保存校验会 trim,但运行时签名未 trim

已修复。最终 Solana signer 入口统一 TrimSpace,清理后的同一个值同时用于 payer 派生校验和 SDK payload 签名。补充了含空格、换行、tab 的有效 key 实际签名回归。

controller/relay.go:settlement_unknown 隐藏原始 401/403/429/5xx 健康信号

本项未按建议修改,属于有意的资金安全边界。signed 后的错误既可能是上游故障,也可能是“结算已成功但响应丢失”、响应解析失败、客户端断开或流截断;当前错误分类无法可靠归因。恢复通用 penalty 会让一次模糊错误自动 cooldown/禁用生产现有 Base 渠道。当前请求仍严格禁止 retry/failover,并保留 channel/status/request/payment reconciliation 记录供告警与人工隔离。

relay/channel/blockrun/adaptor.go:建议仅 payment_rejected 跳过处罚,settlement_unknown 继续 cooldown/disable

同上,暂不修改。持续 signed 429/5xx 的确值得隔离,但安全实现应是保留原始 cause、按明确上游健康故障分类、使用阈值和跨节点 quarantine;不能把所有 settlement_unknown 重新交给现有单次 auto-ban。这是独立健康策略,不夹带进本次 Solana 适配。

controller/channel.go:归一化后判断 penalty 导致异常渠道持续被选中

同上,暂不修改。当前设计优先保证 signed 后不产生第二次签名/新 nonce,也避免模糊结算误伤线上 Base。独立 code review 与 architecture review 已重新检查该取舍,结论分别为 APPROVECLEAR

L630-640:创建 Solana 渠道时保留旧 Base URL,且输入只读

已修复。新建 Type 100 + Solana 时始终强制官方 Solana URL;新建 Base 仅在 URL 为空时填默认值,保留 Base 自定义 URL;编辑态完全不自动改 URL。补充纯函数状态矩阵测试。

验证结果:

  • go test ./relay/channel ./relay/channel/blockrun -count=1
  • go test ./controller ./service -run 'BlockRun|Settlement|Payment' -count=1
  • go vet ./relay/channel ./relay/channel/blockrun
  • bun test src/features/channels/lib/channel-form.test.ts(14/14)
  • bun run typecheck
  • bun run build:check
  • changed-file ESLint
  • git diff --check

生产建议:Router 与 newapi-console 均需部署,先上 staging;生产保持 Solana 渠道禁用,先回归并观察现有 Type 100 Base 和 Type 102,再启用单个低余额 Solana 渠道。

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