本文へ移動

独自の確認コンポーネント

ウォレットでの承認前に独自のReactコンポーネントを表示し、同じモーダルのままウォレットの承認へ切り替えます。

独自の確認コンポーネント ​

useSeams().walletの取引メソッドにreviewオブジェクトを渡すと、ウォレットでの承認前に独自のReactコンポーネントを表示できます。 ユーザーが先へ進むと、同じモーダルが独自のコンポーネントからウォレットの承認画面に切り替わります。 ユーザーに見えるダイアログは1つで、別のポップアップは開きません。 処理は、次の順序で進みます。

独自の確認画面 → ウォレットへ進む → ウォレットで承認 → 取引の結果

アプリが表示する購入の確認画面。予測の内容、支払額、受取額、手数料、見積もりの残り時間、Confirm in walletボタン、Back to marketリンク。
1. 独自のコンポーネントが購入の条件を表示します。
同じモーダル内のウォレットの承認画面。アプリのオリジン、コントラクトのアドレス、ネットワーク、署名するアカウント、Confirm with passkeyボタン。
2. 同じモーダルが、その取引のウォレット承認画面を表示します。

wallet.seams.shのCustom Componentsデモで、この流れを試せます。

切り替えの仕組み ​

  1. アプリがreview付きで取引メソッドを呼び出します。SDKがモーダルを開き、その中に独自のコンポーネントを表示します。 originを渡すと、モーダルはアプリの要素の位置から広がります。
  2. ユーザーが条件を確認します。アプリのボタンがcontrols.continueToWallet()を呼び出します。
  3. モーダルのサイズが変わり、クロスフェードでウォレットの承認画面に切り替わります。 この画面は、ウォレットのiframeが呼び出し時の取引から表示します。ユーザーはパスキーで承認します。
  4. ウォレットの承認画面の戻る矢印で、独自のコンポーネントに戻ります。 コンポーネントのローカル状態は保持され、見積もりの期限も変わりません。

独自のコンポーネントは、署名も取引の変更もできません。ウォレットの承認画面を表示する時点を決めるだけです。 署名する取引はウォレットのオリジンが表示するため、アプリのUIに不具合があっても、ウォレットが署名する内容は変わりません。

ホストを配置する ​

SeamsWebProviderと、確認画面が必要とするすべてのアプリ用プロバイダーの内側に、TransactionReviewHostを1つ配置します。 取引を送信するコンポーネントも、このホストの内側に配置してください。

tsx
import { SeamsWebProvider, TransactionReviewHost } from '@seams/wallet/react';

<SeamsWebProvider config={config}>
  <TransactionReviewHost>
    <App />
  </TransactionReviewHost>
</SeamsWebProvider>;

確認画面は、ホストの祖先にあるコンテキストを継承します。 呼び出し元のボタンの周囲にだけ配置したプロバイダーは、確認画面にコンテキストを提供しません。

Reactコンポーネントを渡す ​

renderコールバックは操作用のコントロールを受け取り、コンポーネントを返します。 返すコンポーネントでは、フック、コンテキスト、ローカル状態、React.lazyとSuspenseを使えます。

tsx
review: {
  title: 'Review purchase',
  validity: { kind: 'unbounded' },
  render: (controls) => (
    <PurchaseReview quote={quote} controls={controls} />
  ),
}

この例のPurchaseReviewとquoteは、アプリ側で用意します。 同期コールバックからJSXを返し、フックは返すコンポーネントの内側に配置してください。

ダイアログ、背景、フォーカス管理はSDKが提供します。 別のダイアログやフォーカストラップを重ねずに、内容とボタンを渡してください。

コントロール動作
continueToWallet()ウォレットの承認画面を表示します。署名前に、ユーザーがその画面で承認する必要があります。
cancel()確認処理をキャンセルします。
fail(error)閉じることができるSDK標準のエラー画面を表示します。イベントハンドラーやデータ取得の失敗に使います。

独自のモーダルから始める ​

origin を渡すと、アプリの要素の位置から確認画面が広がります。onVisible を使うと、 確認画面やウォレット承認が表示された時点を取得できます。

tsx
review: {
  title: '入金の確認',
  validity: { kind: 'unbounded' },
  origin: amountCard.getBoundingClientRect(),
  onVisible: ({ stage, rect }) => {
    if (stage === 'review') closeAmountModal();
    trackSurface(stage, rect);
  },
  render: (controls) => <DepositReview controls={controls} />,
}

origin はビューポート基準のピクセルで指定する矩形です。SDK はメソッドの呼び出し時に一度だけ 読み取ります。最初の確認画面にだけ適用され、ウォレット承認がアプリの座標から始まることはありません。 動きを減らす設定では、確認画面はその場で表示されます。

onVisible は stage と rect を受け取ります。stage は、コンポーネントが表示されると review、 ウォレット承認の準備ができると wallet になります。「確認に戻る」でも再び review を通知します。 rect は確定した位置をビューポート基準のピクセルで示します。このコールバックのエラーは取引に影響しません。

確認画面はアプリのモーダルの上に開きます。空のページが見えるフレームを防ぐため、onVisible の中で アプリのモーダルを閉じてください。そのモーダルがフォーカスを持っていた場合、削除後に SDK はフォーカスを 戻せないため、操作の終了時にアプリ側でフォーカスを移動してください。

有効期限を明示する ​

validityは必須です。各呼び出しで、確認対象の取引に期限を設けるかを明示します。

  • { kind: 'unbounded' }:確認の期限を設けません。有効期限のある見積もりを伴わない送金などに使います。
  • { kind: 'expires_at', atMs: quote.expiresAtMs }:見積もりの期限を、ミリ秒単位のUnixタイムスタンプで渡します。 値は正の安全な整数である必要があります。

期限切れは、承認と追加の署名を防ぎます。すでに開始した署名を取り消すことはできません。 期限切れの見積もりを更新し、新しい見積もりで新たに呼び出してください。 既存の見積もりを書き換えても、呼び出し時に取り込まれた取引は更新されません。 取引自体の経済的な条件を保証するには、コントラクトの期限とスリッページ上限を使ってください。

動作するNEARの実装例 ​

ログイン済みのNEARアカウントがある状態で、ホストの内側にこのボタンを配置します。 金額0の自己送金を実行します。ガス代は発生します。試す際は、テストネットのウォレットを使ってください。

tsx
import { useState } from 'react';
import {
  transfer,
  useSeams,
  type BoundNearSigner,
  type TransactionReviewControls,
} from '@seams/wallet/react';

type TransferState =
  | { kind: 'ready'; message: string }
  | { kind: 'pending' }
  | { kind: 'blocked'; message: string };

function TransferReview({ controls }: { controls: TransactionReviewControls }) {
  return (
    <>
      <p>This example sends a zero-value transfer to your own NEAR account. Gas fees apply.</p>
      <button type="button" onClick={controls.continueToWallet}>
        Continue to wallet
      </button>
      <button type="button" onClick={controls.cancel}>
        Cancel
      </button>
    </>
  );
}

function renderReview(controls: TransactionReviewControls) {
  return <TransferReview controls={controls} />;
}

async function submitTransfer(signer: BoundNearSigner, setState: (state: TransferState) => void) {
  setState({ kind: 'pending' });
  try {
    const result = await signer.signAndSendTransaction({
      receiverId: signer.accountId,
      actions: [transfer('0 NEAR')],
      options: {
        confirmationConfig: { uiMode: 'modal', behavior: 'requireClick' },
      },
      review: {
        title: 'Review transfer',
        validity: { kind: 'unbounded' },
        render: renderReview,
      },
    });
    if (result.success) {
      setState({ kind: 'ready', message: `Transfer submitted: ${result.transactionId}` });
      return;
    }
    switch (result.kind) {
      case 'not_submitted':
        setState({ kind: 'ready', message: `${result.error} You can review a new attempt.` });
        return;
      case 'unresolved':
        setState({
          kind: 'blocked',
          message: `Check transaction ${result.transactionId} before another send.`,
        });
        return;
      case 'submission_unknown':
        setState({ kind: 'blocked', message: 'Check wallet activity before another send.' });
        return;
      default:
        return assertNeverTransferResult(result);
    }
  } catch (error) {
    const message = error instanceof Error ? error.message : 'The wallet response was interrupted.';
    setState({
      kind: 'blocked',
      message: `${message} Check wallet activity before another send.`,
    });
  }
}

function assertNeverTransferResult(value: never): never {
  throw new Error(`Unsupported transfer state: ${String(value)}`);
}

export function ReviewedTransferButton() {
  const { wallet } = useSeams();
  const [state, setState] = useState<TransferState>({ kind: 'ready', message: '' });
  if (wallet?.access !== 'wallet' || !wallet.near) return <p>Sign in with a NEAR account to try this example.</p>;

  return (
    <>
      <button
        type="button"
        disabled={state.kind !== 'ready'}
        onClick={submitTransfer.bind(null, wallet.near, setState)}
      >
        Review transfer
      </button>
      <p role="status">
        {state.kind === 'pending' ? 'Waiting for review and wallet approval…' : state.message}
      </p>
    </>
  );
}

期限付き見積もりによる購入の実装例 ​

このEVMの実装例は、上の画像のデモと同じ流れです。 アプリのバックエンドが、期限とマーケットのコントラクトへの呼び出しを含む見積もりを返します。 コンポーネントは残り時間を表示し、見積もりの期限が切れるとConfirm in walletを無効にします。 validityにも同じ期限を渡すため、期限後はSDKもウォレットの承認を止めます。 確認画面はBuy Yesボタンの位置から広がります。

tsx
import { useEffect, useState, type MouseEvent } from 'react';
import {
  TransactionReviewError,
  useSeams,
  type BoundEvmSigner,
  type TransactionReviewControls,
  type TransactionReviewRect,
} from '@seams/wallet/react';

// Your backend prices the trade and builds the call to your market contract.
// `expiresAtMs` is the quote deadline as a Unix timestamp in milliseconds.
export type PurchaseQuote = {
  claim: string;
  pay: string;
  payout: string;
  minimumPayout: string;
  fee: string;
  expiresAtMs: number;
  tx: {
    to: `0x${string}`;
    data: `0x${string}`;
    value: bigint;
    gasLimit: bigint;
    maxFeePerGas: bigint;
    maxPriorityFeePerGas: bigint;
  };
};

type PurchaseState =
  | { kind: 'ready'; message: string }
  | { kind: 'pending' }
  | { kind: 'blocked'; message: string };

function secondsUntil(deadlineMs: number): number {
  return Math.max(0, Math.ceil((deadlineMs - Date.now()) / 1000));
}

function startCountdown(deadlineMs: number, setSeconds: (seconds: number) => void) {
  const tick = () => setSeconds(secondsUntil(deadlineMs));
  const timer = window.setInterval(tick, 1000);
  return () => window.clearInterval(timer);
}

// The SDK draws the dialog, the `review.title` heading, and the backdrop.
// This component supplies only the content and the two buttons.
function PurchaseReview({
  quote,
  controls,
}: {
  quote: PurchaseQuote;
  controls: TransactionReviewControls;
}) {
  const [seconds, setSeconds] = useState(secondsUntil(quote.expiresAtMs));
  useEffect(() => startCountdown(quote.expiresAtMs, setSeconds), [quote.expiresAtMs]);
  return (
    <>
      <p>{quote.claim}</p>
      <dl>
        <dt>You pay</dt>
        <dd>{quote.pay}</dd>
        <dt>Payout if Yes</dt>
        <dd>{quote.payout}</dd>
        <dt>Minimum payout</dt>
        <dd>{quote.minimumPayout}</dd>
        <dt>Trading fee</dt>
        <dd>{quote.fee} (included)</dd>
      </dl>
      <p>{seconds === 0 ? 'Quote expired' : `Quote expires in ${seconds}s`}</p>
      <button type="button" disabled={seconds === 0} onClick={controls.continueToWallet}>
        Confirm in wallet
      </button>
      <button type="button" onClick={controls.cancel}>
        Back to market
      </button>
    </>
  );
}

function reviewMessage(error: TransactionReviewError): string {
  switch (error.code) {
    case 'cancelled':
      return '';
    case 'review_expired':
      return 'The quote expired. Get a new quote to buy.';
    default:
      return `${error.message} You can review a new attempt.`;
  }
}

async function buy(
  signer: BoundEvmSigner,
  quote: PurchaseQuote,
  origin: TransactionReviewRect,
  setState: (state: PurchaseState) => void,
) {
  setState({ kind: 'pending' });
  try {
    const { txHash } = await signer.signAndSendTransaction({
      chainTarget: 'ethereum-sepolia',
      request: {
        chain: 'evm',
        kind: 'eip1559',
        senderSignatureAlgorithm: 'secp256k1',
        tx: quote.tx,
      },
      options: {
        confirmationConfig: { uiMode: 'modal', behavior: 'requireClick' },
      },
      review: {
        title: 'Review purchase',
        validity: { kind: 'expires_at', atMs: quote.expiresAtMs },
        origin,
        render: (controls) => <PurchaseReview quote={quote} controls={controls} />,
      },
    });
    setState({ kind: 'ready', message: `Purchase submitted: ${txHash}` });
  } catch (error) {
    // A review error means that nothing was signed or sent.
    if (error instanceof TransactionReviewError) {
      setState({ kind: 'ready', message: reviewMessage(error) });
      return;
    }
    const message = error instanceof Error ? error.message : 'The wallet response was interrupted.';
    setState({ kind: 'blocked', message: `${message} Check wallet activity before another buy.` });
  }
}

export function BuyButton({ quote }: { quote: PurchaseQuote }) {
  const { wallet } = useSeams();
  const [state, setState] = useState<PurchaseState>({ kind: 'ready', message: '' });
  if (wallet?.access !== 'wallet') return <p>Sign in to buy.</p>;

  // The review grows from this button. The SDK reads the rectangle once, at the call.
  const onClick = (event: MouseEvent<HTMLButtonElement>) =>
    void buy(wallet.evm, quote, event.currentTarget.getBoundingClientRect(), setState);

  return (
    <>
      <button type="button" disabled={state.kind !== 'ready'} onClick={onClick}>
        Buy Yes
      </button>
      <p role="status">
        {state.kind === 'pending' ? 'Waiting for review and wallet approval…' : state.message}
      </p>
    </>
  );
}

chainTargetには、プロバイダーの設定にあるEVMネットワークを指定してください。 review_expiredエラーは、何も署名されていないことを示します。新しい見積もりを取得し、改めて呼び出してください。

対応するメソッドと設定 ​

useSeams().walletの署名者reviewを受け付けるメソッド
nearsignAndSendTransaction
evmsignTransaction、signAndSendTransaction
temposignTransaction、signAndSendTransaction

読み取り用のウォレット(access: 'external')では、near.prepareOwnerTransaction が review を受け付けます。 確認画面は operation.approve() で開き、結果は型付きで返ります。詳しくは所有者の承認を参照してください。 この呼び出しは常にモーダルと明示的なクリックを使い、確認設定を受け取りません。

独自の確認画面には、ホストされたウォレットiframeと、実効値がuiMode: 'modal'、behavior: 'requireClick'となる確認設定が必要です。 保存済みの設定と互換性がない場合も上書きできるよう、実装例のとおり明示的に設定してください。 遅延後の自動承認には対応しません。Core SDKの呼び出し、メッセージ署名、エクスポート、認証は、このReactの確認APIを受け付けません。

SDKは、呼び出し時に取引の入力と確認設定を取り込みます。 戻り値のPromiseとライフサイクルコールバックの意味は、元のメソッドから変わりません。 「ウォレットへ進む」だけでは取引のPromiseは完了しません。呼び出しは、他のウォレット取引と同じキューに入ります。 呼び出し元やホストのキャンセル・アンマウントは、待機中または署名開始前の処理を無効にします。 署名が始まった後は、ウォレットが実際に返した結果を維持します。

エラーとスタイル ​

NEAR は、確認のキャンセル、送信前の失敗、送信結果が不明な場合を 取引結果で返します。 not_submitted の場合に保留状態を解除し、新しい確認を提示できます。 unresolved のハッシュは、チェーン上の結果を確認するまで保持します。 送出前に確認処理が失敗した場合、署名用コールバックは呼ばれません。

EVM と Tempo では、送出前の確認エラーは TransactionReviewError で reject されます。 署名と通信の失敗は、それぞれの正式なメソッドのエラー契約を維持します。

TransactionReviewError.code意味
cancelled確認をキャンセルしました。
review_expired期限が切れました。再試行の前に、新しい見積もりを取得してください。
review_invalid_input確認設定または取引の入力が無効です。
review_host_unavailable利用可能な確認ホストがありません。
review_owner_disposed呼び出し元またはホストを破棄しました。
review_unsupported_modeランタイムまたは確認モードが、この確認APIに対応していません。
review_identity_changed処理中にウォレットの識別情報が変わりました。
review_render_failed描画に失敗したか、アプリがfail(error)を呼び出しました。
review_prepare_timeout確認画面の読み込み、またはウォレットの準備がタイムアウトしました。

任意のreview.classNameで、確認画面のラッパーにスタイルを適用できます。 アプリのCSSは利用できますが、ポータルによってDOMの祖先が変わります。 祖先セレクター、継承するCSS変数、CSS-in-JSの挿入先は、調整が必要な場合があります。 SDKのライト・ダークと形状のトークンは引き続き利用できます。表示内容は、アプリのCSPを満たす必要があります。

アプリ側の確認画面から開いたウォレット承認画面には、Back to review(確認画面に戻る)が表示されます。 戻ると、確認コンポーネントのローカル状態と、元の見積もりの期限を維持します。 再度進むと、新たな取引を送出せずに、同じ保留中の承認を再開します。 共有モーダルは、どちらの方向でもサイズを調整し、画面をクロスフェードで切り替えます。 戻る操作は、ウォレット承認を送信するまで利用できます。取引の入力を変える場合は、新たに呼び出してください。