今年早些时候,我们的稳定币支付总额突破10亿美元。值得一提的是,距离我们处理首笔稳定币交易还不到12个月。这一切始于少数客户提出一个理应简单的需求:能够接收来自全球客户的稳定币支付,并将这些资金 以美元结算- 快速且可预测,全程无需承担高昂手续费。现有的离场解决方案意味着1-3%的手续费、多日结算周期,以及极其漫长且令人沮丧的合规流程。我们发现了机遇,并在两周内打造出最小可行产品(MVP)。短短一个月内,我们便实现了真实且显著的交易量。
挑战在于实现其扩展。
稳定币可在任何时间、任何地点实现秒级结算。传统银行体系却无法做到——它们依赖批量处理、工作日截止时限和多日结算窗口。当新加坡客户在周六凌晨2点向你发送5万美元USDC时: 链上结算仅需数秒。但ACH转账至你银行账户的流程要到周一才启动,周三才能到账,期间还可能滞留在审核环节。连接这两个世界意味着需要协调两个原本互不兼容的系统——它们各自拥有独立的状态机制。
本文介绍了我们构建的两个基础设施原语,旨在使稳定币的流动行为达到银行级资金流动的标准:
1. 资金流动一种用于长期运行的、跨多系统的金融工作流的声明式编排引擎
2. 我们的 链上执行框架可靠链上操作的生命周期模型
其余所有内容——出金通道、入金通道以及我们的非托管型全球美元账户——都是通过组合这些基础模块构建而成。若您从本文中只能记住一点:稳定币很简单;稳定币 银行业 不是。
当资金在多个外部系统间流动时,临时协调已远远不够。你需要一种声明式表达整个资金流的方式:明确规定应发生什么、响应哪些事件、提供哪些保障。同时,即使流程中途出现故障,该资金流也必须具备可审计性、可恢复性及正确性。这正是资金流管理为我们带来的价值。
软件编排中多数挑战在于优雅处理故障。而金融编排面临更严苛的约束: 资金已经开始流动.
考虑即时存款流程。客户收到10,000美元USDC。我们立即为其账户记账(在基础ACH结算前),使其能即时调用这笔资金。后台系统已自动生成贷款。数日后ACH资金到账时,我们将收取还款及相关费用。
这涉及两个外部系统中的四项操作:加密资产清算、贷款发放、ACH结算、费用收取。每个步骤都依赖于前一步,且任何步骤都可能失败。使用分散状态和临时处理程序无法管理这种流程。
资金流是一个基于声明式规则的编排系统。其核心抽象包含三个部分:
活动 这些是事件信号,表明发生了某些情况——例如ACH结算完成、资金到账、卡授权成功等。事件既可能来自外部系统,也可能由内部系统触发。
规则 定义事件发生时应采取的响应措施。每条规则都指定了一组触发事件及其对应的执行序列。
副作用 规则是针对事件采取的操作:发起转账、创建保留、发放贷款、收取费用。规则仅触发一次。当匹配事件首次到达时,副作用按顺序执行,规则即被消耗。这确保了流程上下文内的幂等性。
另一种选择是强制编排:处理程序调用处理程序,状态分散在各个表中,所谓的"流程"仅存在于代码片段之间的隐式协调之中。
对于简单的流程,这种方法可行。但面对涉及合规保留和部分失败的多日、多系统金融操作时,它便难以维护。错误处理是临时性的,恢复路径隐含其中。六个月后,无人能确切回答"若步骤2成功后步骤3失败会怎样?"
声明式规则颠覆了模型。你需要显式定义状态机: 这些 事件触发 这些 动作。编排引擎负责处理执行、持久化和恢复。流程 是 文档。
FoF为我们提供了四个可依赖的不变量:
1. 幂等性 - 每条规则在每个流上下文中仅触发一次,无论是否存在重复事件或重试操作。
2. 确定性对账 - 在相同事件下,流程将解析为相同状态
3. 完全可审计性 - 每次副作用执行都会被追溯至触发事件的源头
4. 可组合性 - 复杂流程由简单规则构建而成,这些规则在组合时不会形成单一整体。
我们通过节点记录追踪每个副作用的执行过程——每个节点都与其父节点相连,形成完整的执行树。当合规性需要审计轨迹时,我们能够追溯其在系统中的精确路径。
Root Node (flow.begin)
├── Node: CreateOffRampTransaction
├── Node: SendNotification
└── Node: CreateSettlementRule
└── (child rule, fires later)
├── Node: CollectLoanRepayment
├── Node: CollectFees
└── Node: ResolveTransaction
规则可衍生子规则。这正是复杂的多步骤流程得以组合而不致僵化的机制。当创建分流交易时,初始规则不会试图处理所有内容,而是设置 未来 规则——等待后续事件发生的监听器:
createRule({ events: ['flow.begin'] })
.addSideEffect(CreateOffRampTransaction)
.addSideEffect(SendNotification)
.addSideEffect(
CreateRuleSideEffect.create({
rules: createRule({
events: ['inbound_ach.completed', 'completed'],
})
.addSideEffect(CollectLoanRepayment)
.addSideEffect(CollectFees)
.addSideEffect(ResolveTransaction)
.buildRule(),
})
)
.buildRule();
结算逻辑并非作为等待调用的死代码存在,而是以规则形式存在,静待其事件触发。当银行服务商的Webhook在数日后抵达时,规则便会触发,流程随之继续。父规则被执行完毕后,子规则将承接上下文继续执行。
这也意味着流程具有任意组合性。想要实现即时存款?很简单。添加一条规则来发放贷款并设置还款规则即可。每个关注点都是独立的,但它们共同组合成一个连贯的流程。
并非所有副作用都具有同等重要性。有些需要与数据库事务保持原子性,有些会调用外部API,有些则是执行后即忘的操作。
| Method | Execution | Use Case |
| addSideEffect | Synchronous, same DB transaction | Async, no transaction |
| addAsyncSideEffect | Async via Temporal | External API calls, long-running operations |
| addAsyncNonTransactionalSideEffect | Async, no transaction | Notifications, logging, analytics |
最大的好处在于FoF如何改变了Slash工程师编写编排代码的方式。没有它,每位工程师解决相同问题的方式都各不相同。人人都在重复发明轮子,而这些轮子形状各异。
FoF 提升了底线。你来定义。 什么 应该发生,而不是 如何 处理所有故障模式。该框架负责执行、持久化和可观测性。新工程师只需阅读流程定义即可理解,无需追溯多层命令式代码。当抽象层强制要求你明确事件、副作用和状态转换时,编写低质量的编排代码就变得困难得多。
以FoF作为构建资金流的基础,打造核心产品便成了为每条资金流定义正确规则的问题。这种抽象化设计不关心另一端的系统是什么,它只负责协调。
离场通道与入场通道是该引擎协调的"流程",引入了新的外部系统:负责稳定币/法币兑换的加密货币提供商(或场外交易台)。如同任何外部系统,它们按自身规则推送状态更新——我们可据此触发FoF事件。后续只需进行流程组合即可。
离线通道允许客户接收稳定币支付,并在其Slash账户中以美元结算。流程非常简单:
- 客户通过我们加密货币服务商生成的存款地址接收USDC或USDT。
- 服务商检测到存款,将其变现为美元,并发起ACH或电汇。
- 我们的银行服务商接收了入账转账。
- 我们将该转账与原始交易进行对账,并记入账户贷方。
对于即时存款——即我们立即向客户记账,并在ACH结算时收取还款——其流程包含贷款发放、还款收取和费用捕获。每个环节都是独立的规则,监听其对应事件,共同构成单一连贯的流程。该流程的FoF定义大致如下:
const offRampInstantDepositFlow = createRule({
name: 'instant_deposit',
events: ['provider.deposit_received'],
})
.addSideEffect(
DisburseLoan.create((ctx) => ({
accountId: ctx.accountId,
amount: ctx.providerDeposit.amount,
}))
)
.addSideEffect(
// The settlement logic doesn't run immediately
// it waits as a rule until the ACH actually settles
// which might be days later
CreateRule.create((ctx) => ({
traceId: ctx.deposit.ach_trace_number,
rule: createRule({
events: ['inbound_ach.settled'],
})
.addSideEffect(RecollectLoan.create({ loanId: ctx.loanId }))
.addSideEffect(
CollectFees.create({ depositId: ctx.providerDeposit.id })
),
}))
);
入场通道则相反:客户从Slash账户发送美元,并在外部钱包接收稳定币。流程如下:
- 客户发起向目标钱包地址的转账
- 我们将在其账户中预留该金额及相关费用
- 我们将通过ACH或电汇向加密货币服务商的存款指令发送款项
- 服务商收到资金后,将稳定币交付至指定目的地。
const providerAccountId = '...';
const userAccountId = '...';
const onRampFlow = createRule({
name: 'on_ramp',
events: ['flow.begin'],
})
.addSideEffect(
InitiateTransfer.create((ctx) => ({
type: 'wire',
source: userAccountId,
destination: providerAccountId;
// ...
}))
)
.addSideEffect(
CreateRule.create((ctx) => ({
traceId: ctx.transaction.id,
rule: createRule({
events: ['outbound_wire.completed'],
})
.addSideEffect(CaptureFees.create({ transactionId: ctx.transaction.id }))
}))
)
.addSideEffect(
CreateRule.create((ctx) => ({
traceId: ctx.transaction.id,
rule: createRule({
events: ['provider.delivery_failed'],
})
.addSideEffect(CancelPendingFees.create({ transactionId: ctx.transaction.id }))
.addSideEffect(InitiateTransfer.create({
type: 'book',
from: operatingAccountId,
destination: userAccountId,
description: 'Refund',
// ...
})),
}))
);
值得注意的是,这几乎不需要新建任何基础设施。我们为离线通道构建的FoF框架和协调逻辑得以直接沿用。在线通道虽采用不同的规则来监听不同事件,但底层机制完全相同。
FoF解决了法币层面的协调问题——银行通道、服务商、合规要求。但当我们开始构建全球美元时,又面临新的挑战:区块链本身。将交易上链、确认实际落链、处理失败与链重组,并从结果中推导出准确状态——这完全是另一类协调难题。我们需要与FoF同等的保障机制,但这次要应用于链上执行。
我们在所有区块链操作中采用统一模式:
1. 意图声明我们的目标
2. 执行提交交易并引导其进入区块包含
3 核对处理已确认区块,更新内部状态,触发下游流程
如果你来自传统金融领域,这个类比就很直白:
每个阶段都有独特的职责和故障模式。本节剩余部分将详细说明我们如何构建每个层级。
在执行任何操作之前,我们需要定义 什么 我们正在执行。区块链交易本质上是一条指令(带参数调用的函数),但区块链无法理解人类可读的指令。所有内容都会被编码为 调用数据 - 一个十六进制字节组成的数据块,用于指定要调用的函数及传递的参数。
例如,一个简单的USDC转账——"向地址X发送500 USDC"——将变为:
0xa9059cbb0000000000000000000000007e2f5e1fd4d79ed41118fc6f59b53b575c51f182000000000000000000000000000000000000000000000000000000001dcd6500
这样的原始调用数据是晦涩难懂的,无法提供任何关于 为什么 资金流动了。当你构建需要追踪业务背景的系统时——不仅要记录资产转移,还要明确这是针对发票#1234的费用收取——就必须保留这种背景信息。
我们通过建立类型化调用定义的注册表来解决这个问题:
```tsx
export const erc20Transfer = blockchainCallRegistry.define(
defineBlockchainCall({
key: 'erc20.transfer',
abi: 'function transfer(address to, uint256 amount)',
request: {
input: tbox.obj({
contract: tbox.hex(),
recipient: tbox.hex(),
amount: tbox.bigIntStr(),
}),
categories: [
'outbound_wire_transfer',
'outbound_ach_credit',
'outbound_transfer_fee',
],
tags: [],
context: tbox.obj({
relatedAuthorizationId: tbox.opt(tbox.id('authorizationRequest')),
}),
},
encode: ({ input, encodeFunctionData }) => ({
to: input.contract,
data: encodeFunctionData([input.recipient, BigInt(input.amount)]),
value: bigIntStr(0n),
}),
})
);
```
工程师使用领域术语——合同、收款方、金额——而非十六进制字符串进行工作。注册系统负责验证输入、处理编码,并保存后续所需的元数据:类别、标签和业务背景。
创建呼叫变得非常简单:
const call = erc20Transfer.create({
walletId: 'wallet_12345',
chain: 'base',
request: {
input: {
contract: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
recipient: BASE_USDC_FEE_ACCOUNT,
amount: bigIntStr(25_000_000n), // 25 USD
},
category: 'outbound_transfer_fee',
tags: [],
description: 'USDC Fee Transfer - SLASH FINANCE',
context: {}
},
})
每次调用都会成为一个BlockchainCall记录:
interface BlockchainCall {
id: string;
type: string; // 'erc20.transfer'
chain: Chain;
walletId: string;
to: Hex; // Target contract
value: BigIntStr; // ETH value (usually 0)
data: Hex; // Encoded calldata
request: BlockchainCallRequest; // Original typed request with metadata
// Intent association
intentId: string;
indexInBatch: number // Position in the batched transaction
}
我们将区块链调用视为工作的原子单元。一笔交易可能批量处理多个调用,但每次调用都代表单次可追溯的操作。请求字段除编码字节外,还完整保留了类型化输入——包括所有元数据和任意上下文信息。正是这些元数据使我们在将链状态与业务操作进行对账时,能够回答"这笔500美元的转账用途是什么?"的问题。
提交交易看似简单。实际上,在"发送交易"和"交易成功"之间,存在着一片布满地雷的危险地带。
当您提交一笔交易时,它不会直接进入区块。而是进入 内存池- 一个等待区,交易在此停留直至区块生产者将其拾取。等待期间,交易可能被丢弃(内存池已满)、被更高手续费的交易取代,或陷入僵局(当前网络条件下Gas价格过低)。
气体 以太坊网络正是通过这种方式为计算定价。每次操作都需要消耗gas,而gas则需用网络原生代币支付。提交交易时,你需设定愿意支付的最高gas价格。若提交后网络拥堵加剧,你的gas价格可能失去竞争力——交易将滞留在内存池中等待,甚至可能永远无法执行。
即使交易已进入区块,也并非真正最终确定。区块链可能经历 重组(reorgs)- 网络会丢弃近期区块并用另一条区块链取代的情况。你以为已确认的交易可能消失。这种情况在成熟网络中较为罕见,但当涉及真实资金时,"罕见"不等于"绝无可能"。
这些故障分别发生在不同层级:Gas估算、签名、提交、确认。而每种故障的恢复都需要不同的补救措施——卡住的交易需要提高Gas费用重新提交,无效签名需要重新签名,重组则需要整个流程重新尝试。
我们通过将执行生命周期建模为四个实体的层次结构来处理此问题。顶层是我们试图实现的业务成果。其下层级依次处理准备、签署和提交等具体操作。每个层级拥有独立的故障域,可在向上层级升级前自主重试:
BlockchainIntent (what we're trying to achieve)
└── PreparedCall (unsigned transaction, gas estimates locked)
└── PreparedCallExecution (signing attempt)
└── PreparedCallExecutionNode (submission attempt)
区块链意图 代表业务结果:"向此地址转账500 USDC作为发票#1234的付款。"它是顶级协调器,负责追踪整个生命周期,并在必要时触发重试。
预制呼叫 是一个不可变的、无符号的交易,其中包含锁定的gas估计值。如果gas估计值过期(网络状况发生变化),我们将创建一个新的PreparedCall。
预制呼叫执行 代表签名尝试。对于服务器端操作,我们自动签名。对于面向用户的操作(如全球美元),用户通过一次性密码(OTP)进行批准。无论哪种方式,一旦完成签名,我们即可提交。
预制调用执行节点 是一次单次提交尝试。我们将交易发送至网络并轮询其是否被纳入。若因可重试原因失败(网络超时、从内存池丢失),则创建新节点并重新尝试。
每层处理其自身的故障域:
| Failure | Layer | Resolution |
|---|
| Network timeout | ExecutionNode | Retry with new node |
| Retry with new node | Re-org after confirmation | Retry with new node |
| Invalid signature | PreparedCallExecution | Require new signature |
| Gas underestimate | ExecutionNode → Intent | Escalate after N retries |
| Expired gas estimates | PreparedCall → Intent | Escalate, create new PreparedCall |
| Re-org after confirmation | BlockchainIntent | Spawn child intent |
关键洞见在于:当某层耗尽补救手段时,故障会升级至父层。以持续性Gas低估为例: 网络拥塞激增,我们PreparedCall中锁定的gas参数不再具备竞争力。执行节点尝试重试数次,或许拥塞会缓解。但经历N次失败后,它已无力再试。失败升级至执行层,该层达到终止状态后将故障上报至意图层。意图层会生成具有更高gas倍数的子意图,构建新的PreparedCall,循环由此重新启动。
每层处理其自身的故障域,但升级机制是显式的。父意图保留完整历史记录;子意图则获得参数调整后的全新尝试。我们永远不会丢失关于 为什么 我们正在重试。
一笔交易被包含在区块中。接下来呢?
区块链并未直接告知我们发生了1000 USDC的转账。它告诉我们:一笔交易已执行并释放了某些数据。 事件日志我们需要解析这些日志,弄清楚它们的含义,并据此更新我们的内部状态。
事件日志是智能合约传达执行过程中发生情况的方式。当你调用 转移 当USDC合约上的函数被调用时,该合约会发出一个 转移 包含三项数据的事件:发送者、接收者及金额。该事件以日志条目形式记录在交易收据中。
但日志以主题和数据字段的形式编码,其中包含十六进制编码的值。解析这些日志需要了解事件签名并解码参数。原始传输日志的格式如下所示:
// A 5 USDC transfer on Base
{
"address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000007e2f5e1fd4d79ed41118fc6f59b53b575c51f182",
"0x000000000000000000000000a6dbc393e2b1c30cff2fbc3930c3e4ddfc9d1373"
],
"data": "0x00000000000000000000000000000000000000000000000000000000004c4b40",
"transactionIndex": "0x34",
"logIndex": "0x15d",
}
我们如何判断这是转账?每个日志的 主题[0] 是事件签名的keccak256哈希值——在此情况下, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef 是哈希值 转账(源地址索引, 目标地址索引, uint256 值)- 标准的ERC-20转账事件。标记为 索引的 按声明顺序存储在主题数组中,紧随签名哈希之后。对于此Transfer事件, 来自 在 主题[1] 以及 至 在 主题[2]非索引参数如 价值 以ABI编码形式存在于 数据.
从该日志中提取转账详情:
- 来自:
主题[1] (32字节,零填充) → 0x7e2f5e1fd4d79ed41118fc6f59b53b575c51f182 - 至:
主题[2] (32字节,零填充) → 0xa6dbc393e2b1c30cff2fbc3930c3e4ddfc9d1373 - 价值:
数据 解码为 uint256 → 0x4c4b40 = 5,000,000(5 USDC,因为USDC有6位小数)。
掌握解析各类日志的知识所需的认知负担堪称噩梦——正因如此,我们构建了日志处理器,它们能够解析特定事件类型并将其转换为领域状态:
处理器摄取原始日志数据,将其解析为类型化字段(而非十六进制字符串),并生成域实体。
const transferLogProcessor = createLogProcessor({
abi: 'event Transfer(address indexed from, address indexed to, uint256 value)',
requires: ({ log, chain }) => ({
token: TokensDependency.args({ chain, contracts: [log.address] }),
}),
process: ({ log, tx, parsed, deps }) => {
return {
erc20Transfers: [
{
from: parsed.from,
to: parsed.to,
amount: parsed.value,
contract: log.address,
decimals: deps.token.decimals,
txHash: tx.hash,
},
],
};
},
});
包容性与确认性
我们处理两个截然不同的生命周期阶段:
- 包容性 - 交易首次出现在区块中。状态为暂定——该区块仍可能被重组移除。
- 确认 - 区块达到足够深度(后续区块数量足以消除重组的可能性)。状态为最终状态。
这种区别至关重要。我们或许会在纳入时更新界面显示为待处理状态,但不会在确认前触发下游FoF流程。对暂定状态采取行动的代价是无限的。
日志处理器负责处理单个事件,但我们通常需要跨处理器进行协调或添加事务级状态。事务处理器将这些功能整合起来:它们接收所有日志处理器的合并输出,能够对其进行转换、补充或触发额外的下游操作。这也是两阶段生命周期机制发挥作用的地方。 处理交易 在包含时运行——我们生成暂态状态。 处理确认 在区块最终确认后执行——这通常是完成金融操作生命周期的阶段。
const processor = createBlockchainTransactionProcessor()
.requires(({ transaction }) => ({
// Transaction-level dependencies
}))
.processTransaction(async ({ logResults }) => {
const transfers = logResults.erc20Transfers ?? [];
// ...
return {
// ...
erc20Transfers:
transfers.map((t) => ({ type: 'insert', entity: t })),
};
})
.processConfirmation(async ({ logResults }) => {
// Runs when block reaches finality
const flowOfFundsRulesToTrigger = await Promise.all([
findFlowOfFundsRules({
for: logResults.erc20Transfers.map((t) => t.fromAddress),
events: ['on_chain_transfer.source.confirmed'],
}),
findFlowOfFundsRules({
for: logResults.erc20Transfers.map((t) => t.toAddress),
events: ['on_chain_transfer.destination.confirmed'],
}),
]);
// ...
return {
// ...
flowOfFundsRulesToTrigger: flowOfFundsRulesToTrigger.flat(),
erc20Transfers: logResults.erc20Transfers.map((t) => ({
type: 'update',
entity: t,
changes: {
status: 'confirmed',
},
})),
};
})
.build();
当我们的日志处理器生成一个转账记录时,它需要回溯关联到原始的BlockchainCall调用。日志会告诉我们 什么 发生了——资产从A转移到B。区块链调用告诉我们 为什么——这可能是费用收取、供应商付款或退款操作。对于单次调用的简单交易,情况很明确。但对于批量交易(我们将多项操作打包成单次链上交易以节省gas)则更复杂。交易收据仅提供执行过程中生成的日志平面列表,无法显示具体日志对应的调用操作。我们通过调用帧追踪技术解决此问题,相关说明详见下文高级部分。
本节探讨批量交易中的特定技术难题。若您无需处理ERC-4337或批量执行,可直接跳至"全局美元"部分。 我们之前提到,对于简单交易,将日志追溯到其源自的区块链调用(BlockchainCall)是直截了当的。但对于批量交易,情况则不同。
当我们将多项操作批量打包到单个事务中——例如500美元的付款加1美元手续费——两者都会原子性地执行。事务收据会提供执行过程中生成的所有日志的扁平列表:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"type": "0x2",
"status": "0x1",
"logs": [
{
"address": "0x0000000071727de22e5e9d8baf0edac6f37da032",
"topics": [ /* ... */ ],
"data": "0x",
"logIndex": "0x3db",
},
{
"address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
"topics": ["0xddf252ad...", /* ... */ ],
"data": "0x000000000000000000000000000000000000000000000000000000001dcd6500",
"logIndex": "0x3dc",
},
{
"address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
"topics": ["0xddf252ad...", /* ... */ ],
"data": "0x000000000000000000000000000000000000000000000000000000000010c8e0",
"logIndex": "0x3dd",
},
{
"address": "0x0000000071727de22e5e9d8baf0edac6f37da032",
"topics": ["0x49628fd1...", /* ... */],
"data": "0x000000000000000000000000000000000000000000000001000000000000000400000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000009206e5fb300000000000000000000000000000000000000000000000000000000000036f83",
"logIndex": "0x3de",
}
],
"logsBloom": "0x...",
"transactionHash": "0x95aaaa96f3ccefbfcedb1fe5e41d817bc663fcac305ebfc3861f2a189f664cda",
"transactionIndex": "0x18e",
"blockHash": "0xfdc6c014e387c5b6f95edc17a25e81f694a5ad46fc75a247aeb0ad3f5dcd8004",
"blockNumber": "0x25b1de6",
"gasUsed": "0x28c6d",
"effectiveGasPrice": "0x1b3ed0",
"blobGasUsed": "0x24078",
"from": "0x8dc6004dcbfdceeff98f16e7d58b0fde5fab1412",
"to": "0x0000000071727de22e5e9d8baf0edac6f37da032",
}
}
我们获取到执行过程中每条日志生成的扁平数组。通过查看此记录,可识别出日志索引为1和2的两个Transfer事件(两者共享 0xddf252ad... (我们之前讨论过的转账事件签名)。
但究竟哪笔是支付款项,哪笔是手续费?收据上并未说明——日志归属于顶级交易,而非批次内的单次调用。你或许会想:只需按顺序将日志匹配到对应调用即可。但这仅在每次调用仅产生一条日志时成立。简单转账产生一条日志;而交换操作可能产生五条。若无法确定边界,就无法可靠地进行映射。
解决方案最终是 调试跟踪事务- 这是多数人用于调试失败交易的盖斯存档节点RPC方法。但它还具备另一项功能:重放交易并返回完整的调用帧树,同时在调用层次结构的正确深度附加日志。
{
"jsonrpc": "2.0",
"method": "debug_traceTransaction",
"params": [
"0x...",
{
"tracer": "callTracer",
"tracerConfig": {
"withLog": true
}
}
],
"id": 1
}
结果是一个递归嵌套的调用帧结构(为便于阅读而简化)
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"from": "0x8dc6004dcbfdceeff98f16e7d58b0fde5fab1412",
"to": "0x0000000071727de22e5e9d8baf0edac6f37da032",
"input": "0x765e827f...",
"calls": [
{ /* ... */ },
{ /* ... */ },
{
"from": "0x0000000071727de22e5e9d8baf0edac6f37da032",
"to": "0x0000000071727de22e5e9d8baf0edac6f37da032",
"input": "0x0042dc53...",
"calls": [
{
// ...
"calls": [
{
"from": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
"to": "0xe1452ff880739d26f341c2510222e808def38d10",
"input": "0xa9059cbb...",
"logs": [
{
"address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
"topics": [ /* ... */ ],
"data": "0x...",
"position": "0x0",
"index": "0x1"
}
],
"value": "0x0",
"type": "DELEGATECALL"
}
]
},
{
// ...
"calls": [
{
"from": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
"to": "0xe1452ff880739d26f341c2510222e808def38d10",
"input": "0xa9059cbb...",
"logs": [
{
"address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
"topics": [ /* ... */ ],
"data": "0x...",
"position": "0x0",
"index": "0x2"
}
],
"value": "0x0",
"type": "DELEGATECALL"
}
]
}
]
}
],
"logs": [],
"value": "0x0",
"type": "CALL"
}
}
我们将这种递归结构展平为一种模式,该模式保留了树状关系:
export type EvmCallType =
| ('CALL' | 'DELEGATECALL' | 'STATICCALL' | 'CREATE' | 'CREATE2')
| (string & {});
/**
* Represents a single frame in an EVM transaction's call stack trace.
*
* Each call frame captures the execution of one contract call, including:
* - Its position in the call tree (parent, depth, index)
* - Call details (type, from, to, value, input, output)
* - Gas metrics (gas available, gas used)
*
* Call frames form a tree structure via the parentId relationship, where:
* - Root frame (depth 0): The initial transaction call
* - Child frames: Calls made by the parent contract (CALL, DELEGATECALL, etc.)
*/
export interface IEvmCallFrameSchema {
id: Id<'evmCallFrame'>;
transactionHash: string;
parentId: Id<'evmCallFrame'> | undefined;
depth: number;
index: number;
type: EvmCallType;
from: `0x${string}`;
to: `0x${string}` | undefined;
value: string;
input: `0x${string}`;
output: `0x${string}` | undefined;
gas: string | undefined;
gasUsed: string | undefined;
logs: {
address: `0x${string}`;
topics: `0x${string}`[];
data: `0x${string}`;
position: number;
index: number;
}[];
}
const root = debugTraceTransactionResp.result;
const recursivelyBuildSchemas = (
frame: typeof root,
index: number,
parent?: InsertSchema<IEvmCallFrameSchema>
): InsertSchema<IEvmCallFrameSchema>[] => {
const schema: InsertSchema<IEvmCallFrameSchema> = {
id: generateId('evmCallFrame'),
transactionHash,
parentId: parent?.id,
depth: (parent?.depth ?? -1) + 1,
index,
type: frame.type,
from: frame.from,
to: frame.to,
value: (frame.value ? BigInt(frame.value) : BigInt(0)).toString(),
input: frame.input,
output: frame.output,
gas: frame.gas ? BigInt(frame.gas).toString() : undefined,
gasUsed: frame.gasUsed ? BigInt(frame.gasUsed).toString() : undefined,
logs: (frame.logs ?? []).map((log) => ({
...log,
position: Number(log.position),
index: Number(log.index),
})),
};
return [
schema,
...(frame.calls ?? []).flatMap((call, index) =>
recursivelyBuildSchemas(call, index, schema)
),
];
};
const schemas = recursivelyBuildSchemas(root, 0);
const trace: InsertSchema<IEvmTransactionTraceSchema> = {
transactionHash,
chain,
rootFrameId: schemas[0].id,
};
await persistTransactionTrace({
trace,
callFrames: schemas,
});
考虑一个包含两次USDC转账的批量UserOp:一次500美元的支付和一次1.10美元的手续费,如上图所示的执行追踪记录。该追踪记录显示:
EntryPoint.handleOps()
...
└── SmartWallet.executeBatch()
├── Call 0: USDC.transfer(500.00 USDC to 0x...)
│ └── Transfer(from, to, 500000000)
├── Call 1: USDC.transfer(1.10 USDC to Slash)
│ └── Transfer(from, to, 1100000)
└── ...
整个交易现在可以表示为一棵树。这重新定义了整个问题:我们不再从扁平日志数组中推断结构,而是重建执行树——其中调用层次结构明确可见,日志与生成它们的帧相关联。
由此,归因变得简单明了。找到对应于 执行批处理() 调用,遍历其子元素在索引处的值 0..N-1并递归地从每个子树中收集日志。每个子节点索引 0..N-1 直接映射到其对应的BlockchainCall 按批次索引我们现在完全清楚是哪次呼叫生成了哪些日志。
由于几乎每笔交易都需要这种归因,我们将其直接集成到日志处理器中。该处理器能重建完整的调用树,将日志与源帧进行匹配,并解析批处理中的所有区块链调用。每个日志处理器随后会接收其所处理日志的具体调用和帧上下文:
const transferLogProcessor = createLogProcessor({
abi: 'event Transfer(address indexed from, address indexed to, uint256 value)',
requires: ({ call, callFrame }) => ({
// ...
}),
process: ({ call, callFrame }) => {
return {
// ...
};
},
});
完整的归属链:
```
Transfer log
→ EvmCallFrame (index 0 under executeBatch)
→ BlockchainCall (indexInBatch: 0)
→ type: 'erc20.transfer'
→ category: 'outbound_ach_credit'
→ context: { recipientName: 'Acme Corp' }
```
离线通道和上线通道解决了现有客户的需求——那些拥有美国银行账户、希望在法币与加密货币间自由转换的企业。但我们持续收到另一类客户的反馈:国际企业亟需接入美元支付通道,却难以获得相应服务。
若您是阿根廷的软件承包商、尼日利亚的电商商户或东南亚的SaaS企业,开设美国银行账户通常需要在美国注册实体——这意味着律师、注册代理人以及数月的额外开支。许多合法企业实际上被美元经济体系拒之门外,并非因其自身行为,而是源于其注册地。
稳定币改变了这一现状。USDC余额即美元余额。全球美元计划正是我们基于这一前提构建银行基础设施的尝试。
我们构建的Global USD是一个非托管系统。这一决策源于两大因素:监管复杂性与信任问题。
持有客户资金需满足不同司法管辖区的许可要求。非托管架构使我们在众多市场中的许可资质更为简化。在信托层面,客户自主掌控私钥——根据设计,Slash必须获得账户签名者的加密授权后才能发起转账。
核心基本类型是 智能钱包一种智能合约,它作为钱包使用,但具备可编程的访问控制功能。
每个全球美元账户都是由多重签名控制的智能钱包。企业中每位授权成员都持有密钥。转账需经其批准后方可执行。斜杠可 准备 一笔交易,但我们无法 执行 未经签字人授权。
这引发了一个用户体验问题:如果用户掌控私钥,难道不需要手动管理助记词并签署交易吗?
我们采用Privy和Alchemy提供的嵌入式钱包基础设施。当用户创建账户时,私钥会在硬件隔离内存(即"可信执行环境"TEE)中生成。 该密钥真实存在,但设计上确保Slash或其他任何人都无法直接访问。当用户发起转账时,需通过一次性密码(OTP)进行授权,从而允许TEE代为签名。签名后的交易数据随后提交至网络。
从用户角度来看,这就像批准一笔银行转账。从托管角度来看,我们从未接触过私钥。
拉各斯的企业现可持有美元、接收美国客户付款并支付国际供应商款项——全程无需美国银行账户,规避托管风险,同时享有与Slash所有客户同等的审计追踪和合规工作流程。
这就是稳定币的真正意义:它不仅是一种支付方式,更是构建更具包容性的金融体系的基础设施。
我们构建的基础模块不仅用于在法币与加密货币间转移资金,更是Slash所有业务发展的根基。我们正在扩展全球账户服务——让更多企业无论注册地如何,都能接入美元支付通道。 同时我们正在打造全球通用卡:这张高返现、稳定币支持的卡片让客户能随时随地消费账户余额。这两项服务都高度依赖我们在此介绍的协调与执行框架。若您读至此处,且身为工程师渴望在快速成长的企业中为真实客户解决硬核基础设施难题——我们正在招募。