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


wallet.seams.shのCustom Componentsデモで、この流れを試せます。
切り替えの仕組み
- アプリが
review付きで取引メソッドを呼び出します。SDKがモーダルを開き、その中に独自のコンポーネントを表示します。originを渡すと、モーダルはアプリの要素の位置から広がります。 - ユーザーが条件を確認します。アプリのボタンが
controls.continueToWallet()を呼び出します。 - モーダルのサイズが変わり、クロスフェードでウォレットの承認画面に切り替わります。 この画面は、ウォレットのiframeが呼び出し時の取引から表示します。ユーザーはパスキーで承認します。
- ウォレットの承認画面の戻る矢印で、独自のコンポーネントに戻ります。 コンポーネントのローカル状態は保持され、見積もりの期限も変わりません。
独自のコンポーネントは、署名も取引の変更もできません。ウォレットの承認画面を表示する時点を決めるだけです。 署名する取引はウォレットのオリジンが表示するため、アプリのUIに不具合があっても、ウォレットが署名する内容は変わりません。
ホストを配置する
SeamsWebProviderと、確認画面が必要とするすべてのアプリ用プロバイダーの内側に、TransactionReviewHostを1つ配置します。 取引を送信するコンポーネントも、このホストの内側に配置してください。
import { SeamsWebProvider, TransactionReviewHost } from '@seams/wallet/react';
<SeamsWebProvider config={config}>
<TransactionReviewHost>
<App />
</TransactionReviewHost>
</SeamsWebProvider>;確認画面は、ホストの祖先にあるコンテキストを継承します。 呼び出し元のボタンの周囲にだけ配置したプロバイダーは、確認画面にコンテキストを提供しません。
Reactコンポーネントを渡す
renderコールバックは操作用のコントロールを受け取り、コンポーネントを返します。 返すコンポーネントでは、フック、コンテキスト、ローカル状態、React.lazyとSuspenseを使えます。
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 を使うと、 確認画面やウォレット承認が表示された時点を取得できます。
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の自己送金を実行します。ガス代は発生します。試す際は、テストネットのウォレットを使ってください。
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ボタンの位置から広がります。
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を受け付けるメソッド |
|---|---|
near | signAndSendTransaction |
evm | signTransaction、signAndSendTransaction |
tempo | signTransaction、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(確認画面に戻る)が表示されます。 戻ると、確認コンポーネントのローカル状態と、元の見積もりの期限を維持します。 再度進むと、新たな取引を送出せずに、同じ保留中の承認を再開します。 共有モーダルは、どちらの方向でもサイズを調整し、画面をクロスフェードで切り替えます。 戻る操作は、ウォレット承認を送信するまで利用できます。取引の入力を変える場合は、新たに呼び出してください。