Trial offers advance players to Level 3 (Elite Tier) when scouts call the log_trial_offer contract function. This document specifies:
- The data structure for trial offer details
- How to serialize and encode the data for the contract
- Patterns for submitting to the contract
export interface TrialOffer {
/**
* Stellar public key of the scout submitting the trial offer.
* Sourced from wallet connection; validated at contract level.
*/
scout: string;
/**
* Unique identifier of the player receiving the trial offer.
* Must correspond to an existing registered player.
*/
playerID: string;
/**
* Free-form description of the trial offer details.
* May include club name, position offered, coaching/training focus, etc.
* Must be sanitized (HTML/script tags removed) before submission.
* Type: string
*/
details: string;
/**
* Geographic location where the trial will take place.
* Examples: "Lagos, Nigeria", "Cape Town, South Africa", "Nairobi, Kenya"
* Type: string
*/
location: string;
/**
* ISO 8601 date string indicating when the trial starts.
* Format: "YYYY-MM-DD" or full ISO 8601 timestamp
* Examples: "2025-03-15", "2025-03-15T14:30:00Z"
* Type: string
*/
startDate: string;
/**
* Unix timestamp (seconds, NOT milliseconds) representing when the trial offer was recorded.
* Set by the contract on-chain; not submitted from the client.
* Type: number
*/
timestamp: number;
}// Pseudo-code from README.md
log_trial_offer(scout, player_id, details)
→ Record a trial offer on-chain and advance player to Level 3| Parameter | Type | Description |
|---|---|---|
scout |
address | Scout's Stellar public key (source + auth signer) |
player_id |
string | The unique identifier of the player |
details |
string | Serialized trial offer details (see Serialization below) |
- Success: Player is advanced to Level 3; event
trial_offer_loggedis emitted - Error: Contract error code returned (see error codes in README.md)
- Caller must be authenticated via Stellar wallet (scout's Stellar account)
- No subscription check (unlike
pay_to_contactwhich requires active subscription) - Scout must have proper authorization on-chain
Use nativeToScVal() from @stellar/stellar-sdk:
import { nativeToScVal } from '@stellar/stellar-sdk';import { nativeToScVal } from '@stellar/stellar-sdk';
import { buildTx } from '@/lib/contract';
export async function buildLogTrialOffer(
scout: string, // Stellar address
playerId: string, // Player ID
details: string, // Serialized trial offer details
) {
return buildTx(
'log_trial_offer',
[
nativeToScVal(scout, { type: 'address' }), // Convert address
nativeToScVal(playerId, { type: 'string' }), // Convert to string
nativeToScVal(details, { type: 'string' }), // Convert to string
],
scout, // Source account wallet
);
}The details parameter is a single string that can be structured as:
Serialize the offer fields as a JSON string:
const trialOfferDetails = {
location: 'Lagos, Nigeria',
startDate: '2025-03-15',
description:
'Trial for striker position at XYZ Academy. Focus on positioning and finishing.',
};
const detailsString = JSON.stringify(trialOfferDetails);
// Result: '{"location":"Lagos, Nigeria","startDate":"2025-03-15","description":"..."}'Then pass to contract:
nativeToScVal(detailsString, { type: 'string' });For simpler cases, use a structured text format:
const detailsString =
'location: Lagos, Nigeria | startDate: 2025-03-15 | Striker position, focus on finishing';Free-form description if structure is not critical:
const detailsString =
'Trial offer for striker position at XYZ Academy in Lagos. Starting 2025-03-15. Focus on positioning and finishing.';Recommendation: Use Option 1 (JSON) for consistency with other structured data in the platform and to support future parsing.
Type Definition:
export interface PlayerVitals {
name: string;
age: number;
position: string;
region: string;
nationality: string;
}Contract Call (in lib/contract.ts):
export async function buildRegisterPlayer(
wallet: string,
vitals: PlayerVitals,
ipfsHash: string,
) {
return buildTx(
'register_player',
[
nativeToScVal(wallet, { type: 'address' }),
nativeToScVal(vitals), // ← Complex type, no explicit type hint needed
nativeToScVal(ipfsHash, { type: 'string' }),
],
wallet,
);
}Key Insight: nativeToScVal() automatically handles struct serialization when passed a plain JS object.
Type Definition:
export interface ContactDetails {
email?: string;
phone?: string;
telegram?: string;
}Contract Call:
export async function payToContact(
scout: string,
playerID: string,
): Promise<ContactDetails> {
const xdrTx = await buildTx(
'pay_to_contact',
[
nativeToScVal(scout, { type: 'address' }),
nativeToScVal(playerID, { type: 'string' }),
],
scout,
);
// ... sign and submit ...
// Returns ContactDetails object
}export async function subscribe(
scout: string,
tier: SubscriptionTier, // 'basic' | 'pro' | 'elite'
): Promise<void> {
const xdrTx = await buildTx(
'subscribe',
[
nativeToScVal(scout, { type: 'address' }),
nativeToScVal(tier, { type: 'string' }), // ← Enum-like string
],
scout,
);
// ... sign and submit ...
}Following the pattern of usePayToContact:
// hooks/useTrialOffer.ts
'use client';
import { useState, useCallback } from 'react';
import { useWallet } from '@/hooks/useWallet';
import { buildLogTrialOffer } from '@/lib/contract';
import { sanitize } from '@/lib/sanitize';
export function useTrialOffer() {
const { publicKey } = useWallet();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = useCallback(
async (
playerId: string,
location: string,
startDate: string,
description: string,
) => {
if (!publicKey) throw new Error('Wallet not connected');
setLoading(true);
setError(null);
try {
// Sanitize inputs
const sanitizedDescription = sanitize(description);
const sanitizedLocation = sanitize(location);
// Build trial offer details as JSON
const details = JSON.stringify({
location: sanitizedLocation,
startDate,
description: sanitizedDescription,
});
// Build transaction
const xdrTx = await buildLogTrialOffer(publicKey, playerId, details);
// Sign and submit via Freighter
const { signTransaction } = await import('@stellar/freighter-api');
const { NETWORK } = await import('@/lib/stellar');
const { Transaction } = await import('@stellar/stellar-sdk');
const { rpc } = await import('@/lib/stellar');
const signedTxXdr = await signTransaction(xdrTx, {
networkPassphrase: NETWORK,
});
const result = await rpc.sendTransaction(
new Transaction(signedTxXdr, NETWORK),
);
if (result.status === 'ERROR') {
throw new Error(`ContractError: ${JSON.stringify(result)}`);
}
// Confirm transaction
const getResult = await rpc.getTransaction(result.hash);
if ('status' in getResult && getResult.status === 'FAILED') {
throw new Error(`ContractError: log_trial_offer transaction failed`);
}
return result;
} catch (e: any) {
const friendlyError = mapErrorMessage(e.message);
setError(friendlyError);
throw e;
} finally {
setLoading(false);
}
},
[publicKey],
);
function mapErrorMessage(errorText: string): string {
if (errorText.includes('Unauthorized') || errorText.includes('code 10')) {
return 'You are not authorized to log trial offers.';
}
if (errorText.includes('ContractPaused') || errorText.includes('code 9')) {
return 'The contract is currently paused. Please try again later.';
}
return errorText || 'An error occurred while logging the trial offer.';
}
return { submit, loading, error };
}// components/scout/TrialOfferForm.tsx
import { useTrialOffer } from '@/hooks/useTrialOffer';
import { useState } from 'react';
export function TrialOfferForm({ playerId }: { playerId: string }) {
const { submit, loading, error } = useTrialOffer();
const [location, setLocation] = useState('');
const [startDate, setStartDate] = useState('');
const [description, setDescription] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
try {
await submit(playerId, location, startDate, description);
// Show success toast
} catch (err) {
// Error already set in hook
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Trial location"
value={location}
onChange={(e) => setLocation(e.target.value)}
required
/>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
<textarea
placeholder="Trial offer details"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
/>
{error && <div className="error">{error}</div>}
<button type="submit" disabled={loading}>
{loading ? 'Submitting...' : 'Log Trial Offer'}
</button>
</form>
);
}Before submitting trial offer details to the contract, always sanitize inputs:
import { sanitize } from '@/lib/sanitize';
const userDescription = "<script>alert('xss')</script>Trial offer";
const sanitized = sanitize(userDescription);
// Result: "Trial offer" (script tags removed)The sanitize() function:
- Client-side: Uses DOMPurify to strip all HTML/script tags
- Server-side fallback: Simple regex to remove tags
- Result: Plain text safe for storage on-chain
From README.md, these error codes may be returned:
| Code | Error | Cause |
|---|---|---|
| 9 | ContractPaused | Contract is paused |
| 10 | Unauthorized | Scout wallet not authorized or mismatched |
Additional context from usePayToContact pattern:
- Always check wallet connection before calling
- Map error codes to user-friendly messages
- Retry logic may not be needed for trial offers (no subscription gate)
When log_trial_offer succeeds, the contract emits:
trial_offer_logged(scout: address, player_id: string, details: string)
Use case: Listen for this event to trigger UI updates (e.g., refresh player progress level to Level 3).
| Aspect | Details |
|---|---|
| Contract Function | log_trial_offer(scout, player_id, details) |
| Parameters | scout (address), player_id (string), details (string) |
| Details Format | JSON object serialized as string (recommended) |
| Details Content | { location: string, startDate: string, description: string } |
| Encoding | nativeToScVal(details, { type: 'string' }) |
| Serialization | JSON.stringify({ ... }) |
| Sanitization | Yes — use sanitize() for all text fields |
| Authorization | Scout's wallet must be connected and authorized |
| Return | On success: player advances to Level 3, event emitted |
| Error Handling | Map error codes (9, 10) to user-friendly messages |
| Existing Pattern | Follow usePayToContact hook structure |