Skip to main content
This guide walks through building the bilateral instruction flow - where a seller submits their side of a trade, shares a trade reference with the buyer, and the buyer submits a matching instruction to create a settlement.

Prerequisites

  • @keystoneos/react installed
  • KeystoneProvider configured with a session token that has instructions:write and templates:read scopes
  • A React project with TypeScript

How bilateral settlement works

In a bilateral flow, two independent parties agree on a trade off-platform (via phone, email, or OMS). Each party submits their side of the trade to KeyStone. When both sides match, a settlement is created automatically. The key points:
  • The first party to submit gets status: "pending_match" and a trade reference
  • The second party submits with that trade reference and gets status: "matched" plus a settlement ID
  • Either party can be the first to submit - there is no required order

Building the instruction form

Template selector

Start by letting the user pick a settlement template. Templates define the structure of the settlement (roles, leg types, state machine).
// template-selector.tsx
import { useTemplates } from '@keystoneos/react';

interface TemplateSelectorProps {
  value: string | undefined;
  onChange: (slug: string) => void;
}

export function TemplateSelector({ value, onChange }: TemplateSelectorProps) {
  const { templates, isLoading } = useTemplates();

  if (isLoading) return <div>Loading templates...</div>;

  return (
    <div>
      <label style={{ display: 'block', marginBottom: '4px', fontWeight: 600, fontSize: '14px' }}>
        Settlement Template
      </label>
      <select
        value={value ?? ''}
        onChange={e => onChange(e.target.value)}
        style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db' }}
      >
        <option value="" disabled>Select a template</option>
        {templates.map(t => (
          <option key={t.id} value={t.slug}>
            {t.name} - Roles: {t.config.required_roles.join(', ')}
          </option>
        ))}
      </select>
    </div>
  );
}

Trade reference input

When matching an existing instruction, the user provides the trade reference they received from the counterparty.
// trade-reference-input.tsx
interface TradeReferenceInputProps {
  value: string;
  onChange: (value: string) => void;
}

export function TradeReferenceInput({ value, onChange }: TradeReferenceInputProps) {
  return (
    <div>
      <label style={{ display: 'block', marginBottom: '4px', fontWeight: 600, fontSize: '14px' }}>
        Trade Reference (optional)
      </label>
      <input
        type="text"
        value={value}
        onChange={e => onChange(e.target.value)}
        placeholder="e.g. KS-abc12345 - leave empty to create new"
        style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db' }}
      />
      <p style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}>
        If matching an existing instruction, enter the trade reference shared by your counterparty.
      </p>
    </div>
  );
}

The complete instruction form

This form collects role, party details, leg details, template, and optional trade reference.
// instruction-form.tsx
import { useState } from 'react';
import { useSubmitInstruction, useTemplates } from '@keystoneos/react';
import { TemplateSelector } from './template-selector';
import { TradeReferenceInput } from './trade-reference-input';
import { PendingMatchResult } from './pending-match-result';
import { MatchedResult } from './matched-result';

interface FormData {
  role: 'seller' | 'buyer';
  partyReference: string;
  partyName: string;
  walletAddress: string;
  instrumentId: string;
  quantity: string;
  chainId: number;
  templateSlug: string;
  tradeReference: string;
}

const INITIAL_FORM: FormData = {
  role: 'seller',
  partyReference: '',
  partyName: '',
  walletAddress: '',
  instrumentId: '',
  quantity: '',
  chainId: 11155111,
  templateSlug: '',
  tradeReference: '',
};

export function InstructionForm() {
  const [form, setForm] = useState<FormData>(INITIAL_FORM);
  const { submit, result, isSubmitting, error, reset } = useSubmitInstruction();

  const updateField = <K extends keyof FormData>(field: K, value: FormData[K]) => {
    setForm(prev => ({ ...prev, [field]: value }));
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    await submit({
      role: form.role,
      party: {
        external_reference: form.partyReference,
        name: form.partyName,
        wallet_address: form.walletAddress,
      },
      legs: [{
        instrument_id: form.instrumentId,
        quantity: form.quantity,
        direction: 'deliver',
        chain_id: form.chainId,
      }],
      templateSlug: form.templateSlug,
      timeoutAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
      tradeReference: form.tradeReference || undefined,
    });
  };

  const handleReset = () => {
    reset();
    setForm(INITIAL_FORM);
  };

  // Show result screens after submission
  if (result?.status === 'pending_match') {
    return <PendingMatchResult result={result} onReset={handleReset} />;
  }

  if (result?.status === 'matched') {
    return <MatchedResult result={result} />;
  }

  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: '600px', display: 'flex', flexDirection: 'column', gap: '20px' }}>
      <h2 style={{ fontSize: '20px', fontWeight: 700 }}>Submit Settlement Instruction</h2>

      {/* Role selection */}
      <div>
        <label style={{ display: 'block', marginBottom: '4px', fontWeight: 600, fontSize: '14px' }}>Role</label>
        <div style={{ display: 'flex', gap: '12px' }}>
          {(['seller', 'buyer'] as const).map(role => (
            <label key={role} style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
              <input
                type="radio"
                name="role"
                value={role}
                checked={form.role === role}
                onChange={() => updateField('role', role)}
              />
              <span style={{ textTransform: 'capitalize' }}>{role}</span>
            </label>
          ))}
        </div>
      </div>

      {/* Party details */}
      <fieldset style={{ border: '1px solid #e5e7eb', borderRadius: '8px', padding: '16px' }}>
        <legend style={{ fontWeight: 600, fontSize: '14px', padding: '0 8px' }}>Party Details</legend>
        <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
          <div>
            <label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Account Reference</label>
            <input
              type="text"
              value={form.partyReference}
              onChange={e => updateField('partyReference', e.target.value)}
              placeholder="e.g. KSFI-II-4401"
              required
              style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db' }}
            />
          </div>
          <div>
            <label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Display Name</label>
            <input
              type="text"
              value={form.partyName}
              onChange={e => updateField('partyName', e.target.value)}
              placeholder="e.g. Securitize Fund I"
              style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db' }}
            />
          </div>
          <div>
            <label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Wallet Address</label>
            <input
              type="text"
              value={form.walletAddress}
              onChange={e => updateField('walletAddress', e.target.value)}
              placeholder="e.g. 0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18"
              required
              style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db', fontFamily: 'monospace', fontSize: '13px' }}
            />
          </div>
        </div>
      </fieldset>

      {/* Leg details */}
      <fieldset style={{ border: '1px solid #e5e7eb', borderRadius: '8px', padding: '16px' }}>
        <legend style={{ fontWeight: 600, fontSize: '14px', padding: '0 8px' }}>
          {form.role === 'seller' ? 'What You Are Delivering' : 'What You Are Delivering (Payment)'}
        </legend>
        <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
          <div>
            <label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Instrument ID</label>
            <input
              type="text"
              value={form.instrumentId}
              onChange={e => updateField('instrumentId', e.target.value)}
              placeholder={form.role === 'seller' ? 'e.g. OUSG or US09311A1007' : 'e.g. USDC'}
              required
              style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db' }}
            />
          </div>
          <div>
            <label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Quantity</label>
            <input
              type="text"
              value={form.quantity}
              onChange={e => updateField('quantity', e.target.value)}
              placeholder="e.g. 12000000"
              required
              style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db', fontFamily: 'monospace' }}
            />
            <p style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}>
              Amount in the token's smallest unit (e.g., 12000000 for 12M tokens with 0 decimals, or 12000000000000 for 12M USDC with 6 decimals).
            </p>
          </div>
          <div>
            <label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Chain ID</label>
            <select
              value={form.chainId}
              onChange={e => updateField('chainId', Number(e.target.value))}
              style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db' }}
            >
              <option value={1}>Ethereum Mainnet (1)</option>
              <option value={137}>Polygon (137)</option>
              <option value={43114}>Avalanche (43114)</option>
              <option value={11155111}>Sepolia Testnet (11155111)</option>
            </select>
          </div>
        </div>
      </fieldset>

      {/* Template and trade reference */}
      <TemplateSelector value={form.templateSlug || undefined} onChange={slug => updateField('templateSlug', slug)} />
      <TradeReferenceInput value={form.tradeReference} onChange={val => updateField('tradeReference', val)} />

      {/* Submit */}
      {error && (
        <div style={{ padding: '12px', backgroundColor: '#fee2e2', color: '#991b1b', borderRadius: '6px', fontSize: '14px' }}>
          {error}
        </div>
      )}

      <button
        type="submit"
        disabled={isSubmitting || !form.templateSlug}
        style={{
          padding: '12px 24px',
          backgroundColor: isSubmitting ? '#9ca3af' : '#2DD4A8',
          color: '#fff',
          border: 'none',
          borderRadius: '8px',
          fontWeight: 600,
          cursor: isSubmitting ? 'not-allowed' : 'pointer',
        }}
      >
        {isSubmitting ? 'Submitting...' : 'Submit Instruction'}
      </button>
    </form>
  );
}

Handling results

Pending match - share the trade reference

When the submission returns pending_match, the counterparty has not submitted yet. Show the trade reference prominently so the user can share it.
// pending-match-result.tsx
import { useState } from 'react';

interface PendingMatchResultProps {
  result: {
    id: string;
    tradeReference: string;
    status: string;
    role: string;
    createdAt: string;
  };
  onReset: () => void;
}

export function PendingMatchResult({ result, onReset }: PendingMatchResultProps) {
  const [copied, setCopied] = useState(false);

  const copyReference = async () => {
    await navigator.clipboard.writeText(result.tradeReference);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <div style={{ maxWidth: '500px', textAlign: 'center', padding: '40px 24px' }}>
      <div style={{ fontSize: '48px', marginBottom: '16px' }}>
        <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2">
          <circle cx="12" cy="12" r="10" />
          <line x1="12" y1="8" x2="12" y2="12" />
          <line x1="12" y1="16" x2="12.01" y2="16" />
        </svg>
      </div>
      <h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '8px' }}>Waiting for Counterparty</h2>
      <p style={{ color: '#6b7280', marginBottom: '24px' }}>
        Your {result.role} instruction has been submitted. Share the trade reference below with
        your counterparty so they can submit their matching instruction.
      </p>

      <div style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        gap: '8px',
        padding: '16px',
        backgroundColor: '#f9fafb',
        borderRadius: '8px',
        border: '1px solid #e5e7eb',
        marginBottom: '24px',
      }}>
        <code style={{ fontSize: '20px', fontWeight: 700, letterSpacing: '0.05em' }}>
          {result.tradeReference}
        </code>
        <button
          onClick={copyReference}
          style={{
            padding: '6px 12px',
            backgroundColor: copied ? '#dcfce7' : '#e5e7eb',
            color: copied ? '#166534' : '#374151',
            border: 'none',
            borderRadius: '6px',
            cursor: 'pointer',
            fontSize: '13px',
          }}
        >
          {copied ? 'Copied' : 'Copy'}
        </button>
      </div>

      <p style={{ fontSize: '13px', color: '#9ca3af' }}>
        Instruction ID: {result.id}
        <br />
        Submitted: {new Date(result.createdAt).toLocaleString()}
      </p>

      <button
        onClick={onReset}
        style={{
          marginTop: '24px',
          padding: '10px 20px',
          backgroundColor: 'transparent',
          border: '1px solid #d1d5db',
          borderRadius: '6px',
          cursor: 'pointer',
        }}
      >
        Submit Another Instruction
      </button>
    </div>
  );
}

Matched - navigate to settlement

When the submission returns matched, both sides are in. Navigate directly to the settlement detail page.
// matched-result.tsx
interface MatchedResultProps {
  result: {
    id: string;
    tradeReference: string;
    status: string;
    settlementId: string | null;
  };
}

export function MatchedResult({ result }: MatchedResultProps) {
  return (
    <div style={{ maxWidth: '500px', textAlign: 'center', padding: '40px 24px' }}>
      <div style={{ fontSize: '48px', marginBottom: '16px' }}>
        <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2">
          <circle cx="12" cy="12" r="10" />
          <polyline points="16 10 11 15 8 12" />
        </svg>
      </div>
      <h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '8px' }}>Settlement Created</h2>
      <p style={{ color: '#6b7280', marginBottom: '24px' }}>
        Both parties have submitted. The settlement is now being processed.
      </p>

      <div style={{ padding: '16px', backgroundColor: '#f0fdf4', borderRadius: '8px', border: '1px solid #bbf7d0', marginBottom: '24px' }}>
        <p style={{ fontSize: '13px', color: '#166534' }}>
          Trade Reference: <strong>{result.tradeReference}</strong>
        </p>
        <p style={{ fontSize: '13px', color: '#166534', marginTop: '4px' }}>
          Settlement ID: <strong>{result.settlementId}</strong>
        </p>
      </div>

      <a
        href={`/settlements/${result.settlementId}`}
        style={{
          display: 'inline-block',
          padding: '12px 24px',
          backgroundColor: '#2DD4A8',
          color: '#fff',
          borderRadius: '8px',
          textDecoration: 'none',
          fontWeight: 600,
        }}
      >
        View Settlement
      </a>
    </div>
  );
}

Realistic example flow

Here is how a real trade looks end-to-end: 1. Seller at Securitize submits:
await submit({
  role: 'seller',
  party: {
    external_reference: 'KSFI-II-4401',
    name: 'Securitize Fund I',
    wallet_address: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18',
  },
  legs: [{
    instrument_id: 'OUSG',
    quantity: '12000000',
    direction: 'deliver',
    chain_id: 1,
  }],
  templateSlug: 'dvp-bilateral',
  timeoutAt: '2026-04-02T15:00:00Z',
});
// Result: { status: 'pending_match', tradeReference: 'KS-abc12345', ... }
2. Seller shares KS-abc12345 with buyer via email or OMS. 3. Buyer at Ondo Capital submits with the trade reference:
await submit({
  role: 'buyer',
  party: {
    external_reference: 'ONDO-CAP-8812',
    name: 'Ondo Capital',
    wallet_address: '0x8ba1f109551bD432803012645Hac136c9E2aB7f1',
  },
  legs: [{
    instrument_id: 'USDC',
    quantity: '12120000000000',
    direction: 'deliver',
    chain_id: 1,
  }],
  templateSlug: 'dvp-bilateral',
  timeoutAt: '2026-04-02T15:00:00Z',
  tradeReference: 'KS-abc12345',
});
// Result: { status: 'matched', settlementId: '550e8400-e29b-41d4-a716-446655440000', ... }
4. Settlement is created automatically. Both platforms receive a settlement.created webhook. The settlement starts at INSTRUCTED and the engine begins compliance checks.

Error handling

ErrorWhat happenedHow to handle
TEMPLATE_NOT_FOUNDInvalid template slugCheck available templates with useTemplates()
TRADE_REFERENCE_NOT_FOUNDThe trade reference does not existVerify the reference with the counterparty
TRADE_REFERENCE_ALREADY_MATCHEDBoth sides already submittedCheck if settlement already exists
ROLE_ALREADY_TAKENThis role already has an instructionThe counterparty may have submitted as the same role
VALIDATION_ERRORMissing or invalid fieldsCheck form validation before submission

Next steps

Settlement Dashboard

Build a dashboard to monitor all your settlements.

Real-time Tracking

Track settlement progress with live updates after matching.

useSubmitInstruction Reference

Full API reference for the useSubmitInstruction hook.

Bilateral Instructions Guide

Server-side guide for bilateral instruction flow using the SDK.