Prerequisites
@keystoneos/reactinstalledKeystoneProviderconfigured with a session token that hasinstructions:writeandtemplates:readscopes- 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
Understanding legs
A DvP (Delivery vs Payment) settlement involves two legs:| Leg Type | Description | Seller | Buyer |
|---|---|---|---|
asset_delivery | The tokenized asset being traded | Delivers | Receives |
payment | The payment (e.g., USDC) | Receives | Delivers |
seller + asset_delivery = deliver
seller + payment = receive
buyer + asset_delivery = receive
buyer + payment = deliver
chain_id and instrument_id (the token contract address).
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, template-driven legs, and optional trade reference. Each template defines which leg types are required - for DvP, that isasset_delivery and payment.
// 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';
// Template slug to required leg types. Phase 1: all DvP templates.
// Future: read from TemplateConfig.required_leg_types.
const LEG_TYPES_BY_TEMPLATE: Record<string, string[]> = {
'cross_platform_dvp': ['asset_delivery', 'payment'],
'single_platform_dvp': ['asset_delivery', 'payment'],
};
const LEG_TYPE_LABELS: Record<string, string> = {
asset_delivery: 'Asset Delivery',
payment: 'Payment',
};
function deriveDirection(role: string, legType: string): 'deliver' | 'receive' {
if ((role === 'seller' && legType === 'asset_delivery') ||
(role === 'buyer' && legType === 'payment')) {
return 'deliver';
}
return 'receive';
}
interface LegState {
instrumentId: string;
quantity: string;
chainId: number;
}
interface FormData {
role: 'seller' | 'buyer';
partyReference: string;
partyName: string;
walletAddress: string;
templateSlug: string;
tradeReference: string;
legs: Record<string, LegState>;
}
function buildInitialLegs(legTypes: string[]): Record<string, LegState> {
return Object.fromEntries(
legTypes.map(lt => [lt, { instrumentId: '', quantity: '', chainId: 11155111 }])
);
}
export function InstructionForm() {
const defaultLegTypes = ['asset_delivery', 'payment'];
const [form, setForm] = useState<FormData>({
role: 'seller',
partyReference: '',
partyName: '',
walletAddress: '',
templateSlug: '',
tradeReference: '',
legs: buildInitialLegs(defaultLegTypes),
});
const { submit, result, isSubmitting, error, reset } = useSubmitInstruction();
const legTypes = LEG_TYPES_BY_TEMPLATE[form.templateSlug] ?? defaultLegTypes;
const handleTemplateChange = (slug: string) => {
const newLegTypes = LEG_TYPES_BY_TEMPLATE[slug] ?? defaultLegTypes;
setForm(prev => ({ ...prev, templateSlug: slug, legs: buildInitialLegs(newLegTypes) }));
};
const updateLeg = (legType: string, field: keyof LegState, value: string | number) => {
setForm(prev => ({
...prev,
legs: { ...prev.legs, [legType]: { ...prev.legs[legType], [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: legTypes.map(lt => ({
leg_type: lt,
instrument_id: form.legs[lt].instrumentId,
quantity: form.legs[lt].quantity,
direction: deriveDirection(form.role, lt),
chain_id: form.legs[lt].chainId,
})),
templateSlug: form.templateSlug,
timeoutAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
tradeReference: form.tradeReference || undefined,
});
};
const handleReset = () => {
reset();
setForm({
role: 'seller',
partyReference: '',
partyName: '',
walletAddress: '',
templateSlug: '',
tradeReference: '',
legs: buildInitialLegs(defaultLegTypes),
});
};
// 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={() => setForm(prev => ({ ...prev, 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 => setForm(prev => ({ ...prev, 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 => setForm(prev => ({ ...prev, 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 => setForm(prev => ({ ...prev, 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>
{/* Legs - template driven */}
<fieldset style={{ border: '1px solid #e5e7eb', borderRadius: '8px', padding: '16px' }}>
<legend style={{ fontWeight: 600, fontSize: '14px', padding: '0 8px' }}>
Settlement Legs
</legend>
<p style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
Each leg has its own token address and chain. Direction is auto-derived from your role.
</p>
{legTypes.map((lt, index) => (
<div key={lt}>
{index > 0 && <hr style={{ border: 'none', borderTop: '1px dashed #e5e7eb', margin: '16px 0' }} />}
<div style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{
padding: '2px 10px',
borderRadius: '9999px',
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
background: lt === 'asset_delivery' ? 'rgba(22,163,74,0.1)' : 'rgba(37,99,235,0.1)',
color: lt === 'asset_delivery' ? '#16a34a' : '#2563eb',
border: lt === 'asset_delivery' ? '1px solid rgba(22,163,74,0.3)' : '1px solid rgba(37,99,235,0.3)',
}}>
{LEG_TYPE_LABELS[lt] ?? lt}
</span>
<span style={{ fontSize: '12px', color: '#6b7280', textTransform: 'capitalize' }}>
{deriveDirection(form.role, lt) === 'deliver'
? `${form.role} delivers to ${form.role === 'seller' ? 'buyer' : 'seller'}`
: `${form.role === 'seller' ? 'buyer' : 'seller'} delivers to ${form.role}`}
</span>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Token Address</label>
<input
type="text"
value={form.legs[lt]?.instrumentId ?? ''}
onChange={e => updateLeg(lt, 'instrumentId', e.target.value)}
placeholder="0x..."
required
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db', fontFamily: 'monospace', fontSize: '13px' }}
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Quantity</label>
<input
type="text"
value={form.legs[lt]?.quantity ?? ''}
onChange={e => updateLeg(lt, 'quantity', e.target.value)}
placeholder="e.g. 12000000"
required
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db', fontFamily: 'monospace' }}
/>
</div>
<div style={{ width: '160px' }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Chain ID</label>
<select
value={form.legs[lt]?.chainId ?? 11155111}
onChange={e => updateLeg(lt, 'chainId', Number(e.target.value))}
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db' }}
>
<option value={1}>Ethereum (1)</option>
<option value={137}>Polygon (137)</option>
<option value={43114}>Avalanche (43114)</option>
<option value={11155111}>Sepolia (11155111)</option>
</select>
</div>
</div>
</div>
</div>
))}
</fieldset>
{/* Template and trade reference */}
<TemplateSelector value={form.templateSlug || undefined} onChange={handleTemplateChange} />
<TradeReferenceInput value={form.tradeReference} onChange={val => setForm(prev => ({ ...prev, 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 returnspending_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 returnsmatched, 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 both legs:await submit({
role: 'seller',
party: {
external_reference: 'KSFI-II-4401',
name: 'Securitize Fund I',
wallet_address: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18',
},
legs: [
{
leg_type: 'asset_delivery',
instrument_id: '0x1234...abcd', // OUSG token contract
quantity: '12000000',
direction: 'deliver', // seller delivers the asset
chain_id: 1,
},
{
leg_type: 'payment',
instrument_id: '0xA0b86...6EB48', // USDC contract
quantity: '12120000000000',
direction: 'receive', // seller receives payment
chain_id: 1,
},
],
templateSlug: 'cross_platform_dvp',
timeoutAt: '2026-04-02T15:00:00Z',
});
// Result: { status: 'pending_match', tradeReference: 'KS-abc12345', ... }
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: [
{
leg_type: 'asset_delivery',
instrument_id: '0x1234...abcd', // same OUSG token contract
quantity: '12000000',
direction: 'receive', // buyer receives the asset
chain_id: 1,
},
{
leg_type: 'payment',
instrument_id: '0xA0b86...6EB48', // same USDC contract
quantity: '12120000000000',
direction: 'deliver', // buyer delivers payment
chain_id: 1,
},
],
templateSlug: 'cross_platform_dvp',
timeoutAt: '2026-04-02T15:00:00Z',
tradeReference: 'KS-abc12345',
});
// Result: { status: 'matched', settlementId: '550e8400-e29b-41d4-a716-446655440000', ... }
settlement_id; the settlement starts at INSTRUCTED and the engine begins compliance checks. The first webhook both platforms receive is settlement.state.awaiting_deposits once compliance clears (or settlement.state.compliance_checking if a party is flagged).
Error handling
| Error | What happened | How to handle |
|---|---|---|
TEMPLATE_NOT_FOUND | Invalid template slug | Check available templates with useTemplates() |
TRADE_REFERENCE_NOT_FOUND | The trade reference does not exist | Verify the reference with the counterparty |
TRADE_REFERENCE_ALREADY_MATCHED | Both sides already submitted | Check if settlement already exists |
ROLE_ALREADY_TAKEN | This role already has an instruction | The counterparty may have submitted as the same role |
VALIDATION_ERROR | Missing or invalid fields | Check 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.