> ## Documentation Index
> Fetch the complete documentation index at: https://docs.li.fi/llms.txt
> Use this file to discover all available pages before exploring further.

# lifi.swap

> 通过 LI.FI 聚合器 API 交换代币

export const ComposeItemDetail = ({kind, id}) => {
  const [state, setState] = useState({
    data: null,
    error: null
  });
  const [copied, setCopied] = useState(null);
  useEffect(() => {
    let cancelled = false;
    setState({
      data: null,
      error: null
    });
    const path = kind === 'edge' ? '/compose/zap-packs' : '/compose/manifest';
    const run = async () => {
      try {
        const response = await fetch(`https://composer.li.quest${path}`);
        if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
        const body = await response.json();
        if (!body || body.success !== true) {
          throw new Error(body?.error?.message ?? 'Unexpected response shape');
        }
        if (!cancelled) setState({
          data: body.data,
          error: null
        });
      } catch (err) {
        if (!cancelled) setState({
          data: null,
          error: err.message ?? String(err)
        });
      }
    };
    run();
    return () => {
      cancelled = true;
    };
  }, [kind, id]);
  if (state.error) {
    return <div style={{
      padding: '0.75rem 1rem',
      border: '1px solid #f5c6cb',
      background: '#f8d7da',
      color: '#721c24',
      borderRadius: '6px'
    }}>
        <strong>Failed to load {kind} <code>{id}</code>.</strong>
        <div style={{
      fontFamily: 'monospace',
      marginTop: '0.25rem',
      fontSize: '0.9em'
    }}>{state.error}</div>
      </div>;
  }
  if (!state.data) return <div>Loading {kind} details…</div>;
  const notFound = label => <div style={{
    padding: '0.75rem 1rem',
    border: '1px solid #ffeaa7',
    background: '#fff3cd',
    color: '#856404',
    borderRadius: '6px'
  }}>
      <strong>No {kind} found for id <code>{id}</code>.</strong>
      <div style={{
    marginTop: '0.25rem',
    fontSize: '0.9em'
  }}>
        It may have been renamed or removed. See the <a href={`/composer/${label}`}>{label} catalog</a> for the current list.
      </div>
    </div>;
  const renderSchema = schema => {
    if (!schema) return <div style={{
      opacity: 0.6,
      fontSize: '0.9em'
    }}>No config schema.</div>;
    return <pre style={{
      background: '#0d1117',
      color: '#e6edf3',
      padding: '0.75rem',
      borderRadius: '6px',
      overflow: 'auto',
      fontSize: '0.85em',
      border: '1px solid #30363d'
    }}><code style={{
      color: 'inherit',
      background: 'transparent'
    }}>{JSON.stringify(schema, null, 2)}</code></pre>;
  };
  const renderPortRows = ports => {
    if (!ports || ports.length === 0) return <div style={{
      opacity: 0.6,
      fontSize: '0.9em'
    }}>None.</div>;
    return <table>
      <thead>
        <tr>
          <th className="text-left"><strong>Name</strong></th>
          <th className="text-left"><strong>Kind</strong></th>
          <th className="text-left"><strong>Details</strong></th>
        </tr>
      </thead>
      <tbody>
        {ports.map((port, idx) => {
      const {name, kind: portKind, ...rest} = port;
      const entries = Object.entries(rest);
      return <tr key={`${name ?? 'port'}-${idx}`}>
            <td><code>{name ?? '—'}</code></td>
            <td><code>{portKind ?? '—'}</code></td>
            <td>
              {entries.length === 0 ? <span style={{
        opacity: 0.5
      }}>—</span> : entries.map(([k, v]) => <span key={k} style={{
        display: 'inline-block',
        marginRight: '0.5rem'
      }}>
                      <span style={{
        opacity: 0.7
      }}>{k}:</span> <code>{typeof v === 'string' ? v : JSON.stringify(v)}</code>
                    </span>)}
            </td>
          </tr>;
    })}
      </tbody>
    </table>;
  };
  const renderSelectors = selectors => {
    if (!selectors || selectors.length === 0) return <div style={{
      opacity: 0.6,
      fontSize: '0.9em'
    }}>No selectors.</div>;
    return <table>
      <thead>
        <tr>
          <th className="text-left"><strong>Binding</strong></th>
          <th className="text-left"><strong>Source</strong></th>
          <th className="text-left"><strong>Match</strong></th>
          <th className="text-left"><strong>Selection</strong></th>
        </tr>
      </thead>
      <tbody>
        {selectors.map((sel, idx) => <tr key={`${sel.binding ?? 'sel'}-${idx}`}>
            <td><code>{sel.binding ?? '—'}</code></td>
            <td><code>{sel.source ?? '—'}</code></td>
            <td><code>{sel.match ? JSON.stringify(sel.match) : '—'}</code></td>
            <td><code>{sel.selection ? JSON.stringify(sel.selection) : '—'}</code></td>
          </tr>)}
      </tbody>
    </table>;
  };
  const shortAddress = addr => addr && addr.length > 10 ? `${addr.slice(0, 6)}…${addr.slice(-4)}` : addr;
  const copyAddress = async (address, key) => {
    if (!address) return;
    try {
      if (navigator?.clipboard?.writeText) {
        await navigator.clipboard.writeText(address);
      } else {
        const el = document.createElement('textarea');
        el.value = address;
        el.setAttribute('readonly', '');
        el.style.position = 'absolute';
        el.style.left = '-9999px';
        document.body.appendChild(el);
        el.select();
        document.execCommand('copy');
        document.body.removeChild(el);
      }
      setCopied(key);
      setTimeout(() => setCopied(current => current === key ? null : current), 1200);
    } catch {}
  };
  const addressCell = (address, chainId, rowIdx, side) => {
    const key = `${rowIdx}-${side}`;
    const isCopied = copied === key;
    return <>
      <code role="button" tabIndex={0} title={isCopied ? 'Copied!' : `Click to copy ${address}`} onClick={() => copyAddress(address, key)} onKeyDown={e => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        copyAddress(address, key);
      }
    }} style={{
      cursor: 'pointer',
      background: isCopied ? '#d4edda' : undefined,
      color: isCopied ? '#155724' : undefined,
      textDecoration: 'underline dotted',
      textDecorationColor: '#999',
      borderRadius: '3px',
      padding: isCopied ? '0 2px' : undefined
    }}>
        {isCopied ? 'Copied!' : shortAddress(address)}
      </code>
      <span style={{
      opacity: 0.6
    }}> · chain {chainId}</span>
    </>;
  };
  if (kind === 'edge') {
    const packs = state.data;
    const pack = packs.find(p => p.protocol === id);
    if (!pack) return notFound('routing-edges');
    const edges = pack.edges ?? [];
    return <>
      <div style={{
      marginBottom: '0.5rem',
      fontSize: '0.9em',
      opacity: 0.75
    }}>
        Protocol <code>{pack.protocol}</code> · <code>{edges.length}</code> edges
      </div>
      <h3>Edges</h3>
      {edges.length === 0 ? <div style={{
      opacity: 0.6,
      fontSize: '0.9em'
    }}>No edges registered.</div> : <table>
            <thead>
              <tr>
                <th className="text-left"><strong>Type</strong></th>
                <th className="text-left"><strong>In</strong></th>
                <th className="text-left"><strong>Out</strong></th>
              </tr>
            </thead>
            <tbody>
              {edges.map((edge, idx) => <tr key={`${edge.type}-${edge.in?.address}-${edge.out?.address}-${idx}`}>
                  <td><code>{edge.type}</code></td>
                  <td>{addressCell(edge.in?.address ?? '', edge.in?.chainId ?? '', idx, 'in')}</td>
                  <td>{addressCell(edge.out?.address ?? '', edge.out?.chainId ?? '', idx, 'out')}</td>
                </tr>)}
            </tbody>
          </table>}
    </>;
  }
  const manifest = state.data;
  let item = null;
  if (kind === 'op') item = (manifest.operations ?? []).find(op => op.id === id); else if (kind === 'materialiser') item = (manifest.materialisers ?? []).find(m => m.kind === id); else if (kind === 'guard') item = (manifest.guards ?? []).find(g => g.kind === id);
  if (!item) return notFound(`${kind}s`);
  return <>
    <div style={{
    marginBottom: '0.5rem',
    fontSize: '0.9em',
    opacity: 0.75
  }}>
      Manifest version <code>{manifest.manifestVersion}</code>
      {item.description ? <> · {item.description}</> : null}
    </div>
    <h3>Signature</h3>
    <ul>
      <li><strong>Kind:</strong> <code>{kind}</code></li>
      <li><strong>Id:</strong> <code>{id}</code></li>
      {item.accepts ? <li><strong>Accepts:</strong> <code>{item.accepts}</code></li> : null}
    </ul>
    {kind === 'op' ? <>
      <h3>Input ports</h3>
      {renderPortRows(item.inputs)}
      <h3>Output ports</h3>
      {renderPortRows(item.outputs)}
    </> : null}
    {kind === 'guard' ? <>
      <h3>Selectors</h3>
      {renderSelectors(item.compatibility?.selectors)}
    </> : null}
    <h3>Config schema</h3>
    {renderSchema(item.configSchema)}
  </>;
};

<Note>
  `lifi.swap` 是 Composer 支持的众多 op 之一。它在这里拥有专属页面，是因为值得深入讲解 —— 有关每一个 op 的完整、实时列表，请参见 [Op 目录](/composer/composer-api/ops)。
</Note>

`lifi.swap` 是 flow 中主要的跨代币原语。它在 `amountIn` 端口消费一个资源，并在 `amountOut` 端口产出一个 `resourceOut` 代币的新资源，使用 LI.FI 的聚合器在各个 DEX 和桥之间挑选最优路由。由于聚合器已经返回了最小产出保证（`providesMinimum`），你通常**不**需要额外的滑点 guard —— 只需在 config 中传入 `slippage`，最小值就会被烘焙进报价中。

对于任何以标准 ERC-20 结束的链上或跨链交换，使用 `lifi.swap`。对于存入金库或借贷市场，请优先使用 [`lifi.zap`](/composer/composer-api/ops/lifi-zap)，它会在交换之后额外链式执行一个路由边存入操作。

## 理解 `unspentIn`

当你编译 flow 时，`lifi.swap` 会向 LI.FI 聚合器请求一份具体的报价，并固定报价中的输入金额。链上交换会**恰好**花费这个被固定的金额 —— 不多也不少。

如果实际流入 `amountIn` 的运行时金额*大于*被固定的金额，差额会作为输入代币的一等线性资源，出现在第二个输出端口 `unspentIn` 上。这通常发生在 `amountIn` 的值只有在运行时才完全确定的情况 —— 例如当它来自某个上游 op、而该 op 的输出与用于推导报价的预览不同时，或者当输入是由模拟解析出来时。如果实际的 `amountIn` 小于被固定的金额，交易就会回滚。

`unspentIn` 的语义：

* **与 `amountIn` 相同的代币。** 剩余部分保留输入资源；它不会被交换或销毁。
* **精确划分。** `unspentIn` = `amountIn` 减去被报价交换所消费的金额。该 op 会忽略执行地址上任何预先存在的 dust —— 它只核算被交进来的那部分输入。
* **完全可组合。** 把它当作任何其他线性资源输出来处理：将它绑定到下游节点（合并、再次交换、扫走给某个接收方），它**不会**表现为一个终端。
* **为零时省略。** 如果运行时金额与报价精确匹配，`unspentIn` 会从 `producedResources` 中被抑制，而不是作为一个嘈杂的零终端出现。
* **输入和输出必须不同。** 将 `resourceOut` 配置为与 `amountIn` 相同的资源是一个校验错误 —— 请改用空操作节点或跳过该交换。

<Note>
  `amountOut` 被报告为交换所产生的**增量**（调用后余额减去调用前余额），而不是调用后的总余额。即便执行地址已经持有部分输出代币，这也与「本次交换产出了什么」相符。
</Note>

<ComposeItemDetail kind="op" id="lifi.swap" />

## 示例

在 Ethereum 主网上交换 WETH → USDC：

```ts theme={"system"}
const builder = sdk.flow(1, {
  name: 'swap-weth-to-usdc',
  inputs: { amountIn: resources.erc20(WETH, 1) },
});

builder.lifi.swap('swap', {
  bind: { amountIn: builder.inputs.amountIn },
  config: {
    resourceOut: resources.erc20(USDC, 1),
    slippage: 0.03,
  },
});
```

完整可运行的配方：[Swap and zap](/composer/composer-api/recipes/swap-and-zap)。
