Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.git
.env
**/node_modules
**/dist
**/coverage
**/*.log

apps/web/src
apps/web/public
programs
tests
tools
avatar_images
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
NEXT_PUBLIC_WORLD_WS=ws://localhost:2567
VITE_WORLD_WS=ws://localhost:2567
VITE_WORLD_HTTP=http://localhost:2567
VITE_API_BASE_URL=http://localhost:2567
FRONTEND_BASE_URL=http://localhost:5173
# Durable Match accounts and wallet joins use base Solana; private PER reads use MagicBlock TEE.
OUTCRY_BASE_RPC=https://api.devnet.solana.com
OUTCRY_DEPLOY_RPC=https://api.devnet.solana.com
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ jobs:
with:
node-version: 24
cache: pnpm
- uses: solana-foundation/github-actions/setup-all@v0.2.15
with:
solana_version: 3.1.10
anchor_version: 1.0.2
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
- run: NO_DNA=1 anchor build
- run: NO_DNA=1 anchor test
- run: NO_DNA=1 anchor test --skip-deploy --skip-build --skip-lint
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
docs/*
!docs/
!docs/AUTHORITY.md
node_modules
.pnpm-store
target
Expand All @@ -9,3 +8,4 @@ dist
.env
artifacts
*.md
!docs/AUTHORITY.md
6 changes: 2 additions & 4 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import WorldCanvas from "./world/WorldCanvas";
import { API_BASE_URL } from "./config";

type WorldCard = {
id: string;
Expand All @@ -16,9 +17,6 @@ const initialWorlds: WorldCard[] = [
{ id: "kyoto-lanterns", name: "Kyoto Lanterns", online: 0, pits: 2, ready: true },
];

const worldHttp = import.meta.env.VITE_WORLD_HTTP ||
(import.meta.env.VITE_WORLD_WS || "ws://localhost:2567").replace(/^ws/, "http");

function worldFromUrl() {
const id = new URLSearchParams(window.location.search).get("world");
return initialWorlds.some((world) => world.ready && world.id === id) ? id : null;
Expand All @@ -42,7 +40,7 @@ export default function App() {
let active = true;
const refreshWorlds = async () => {
try {
const response = await fetch(`${worldHttp}/api/worlds`);
const response = await fetch(`${API_BASE_URL}/api/worlds`);
if (!response.ok) return;
const payload = await response.json() as { worlds?: Array<{ id: string; online: number; activePits: number }> };
if (!active || !Array.isArray(payload.worlds)) return;
Expand Down
20 changes: 16 additions & 4 deletions apps/web/src/chain/idl/outcry.json
Original file line number Diff line number Diff line change
Expand Up @@ -1548,7 +1548,10 @@
},
{
"name": "authority",
"signer": true
"signer": true,
"relations": [
"match_state"
]
}
],
"args": []
Expand Down Expand Up @@ -1909,7 +1912,10 @@
{
"name": "authority",
"writable": true,
"signer": true
"signer": true,
"relations": [
"match_state"
]
},
{
"name": "system_program",
Expand Down Expand Up @@ -3197,7 +3203,10 @@
},
{
"name": "authority",
"signer": true
"signer": true,
"relations": [
"match_state"
]
}
],
"args": [
Expand Down Expand Up @@ -3341,7 +3350,10 @@
},
{
"name": "authority",
"signer": true
"signer": true,
"relations": [
"match_state"
]
},
{
"name": "magic_program",
Expand Down
41 changes: 36 additions & 5 deletions apps/web/src/chain/matchActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,28 @@ function integerBytes(value: number | bigint, signed = false) {
return data;
}

function startMatchSimulationError(value: { err: unknown; logs?: string[] | null; unitsConsumed?: number | null }, instructionLabels: string[]) {
const instructionError = typeof value.err === "object" && value.err !== null && "InstructionError" in value.err
? (value.err as { InstructionError?: unknown }).InstructionError
: undefined;
const failedInstructionIndex = Array.isArray(instructionError) && typeof instructionError[0] === "number"
? instructionError[0]
: undefined;
const failedInstruction = failedInstructionIndex === undefined
? "unknown_instruction"
: instructionLabels[failedInstructionIndex] ?? `instruction_${failedInstructionIndex}`;
const logs = value.logs ?? [];
const relevantLog = [...logs].reverse().find((log) => /AnchorError|Error Code|failed|custom program error|constraint/i.test(log));
console.error("[outcry][match-start][simulation]", {
failedInstructionIndex,
failedInstruction,
err: value.err,
logs,
unitsConsumed: value.unitsConsumed ?? null,
});
return `match_start_bundle_simulation_failed:${failedInstruction}:${relevantLog ?? JSON.stringify(value.err)}`;
}

function quoteSessionKey(input: { baseRpcUrl: string; matchAddress: string; dealer: PublicKey; programId: PublicKey }) {
return [input.baseRpcUrl, input.programId.toBase58(), input.matchAddress, input.dealer.toBase58()].join(":");
}
Expand Down Expand Up @@ -398,9 +420,15 @@ export async function startMatchOnchain(input: {
const connected = wallet.publicKey ? { publicKey: wallet.publicKey } : await wallet.connect();
const authority = new PublicKey(connected.publicKey);
if (authority.toBase58() !== input.hostAddress) throw new Error("host_wallet_mismatch");
const programId = new PublicKey(input.programId ?? PROGRAM_ID);
const connection = createBaseRpcConnection(input.rpcUrl);
const matchInfo = await connection.getAccountInfo(new PublicKey(input.matchAddress), "confirmed");
if (!matchInfo) throw new Error("match_account_unavailable");
if (!matchInfo?.owner.equals(programId)) throw new Error("match_account_unavailable");
const matchSnapshot = decodePublicMatchAccount(input.matchAddress, matchInfo.data);
if (matchSnapshot.authority !== authority.toBase58()) {
throw new Error(`match_authority_wallet_mismatch:connect_${matchSnapshot.authority}`);
}
if (!matchSnapshot.players.includes(authority.toBase58())) throw new Error("match_authority_not_seated");
const transaction = new Transaction();
if (matchInfo.data.length < CURRENT_MATCH_BYTES) {
transaction.add(createMigrateLegacyMatchInstruction({ ...input, hostAddress: authority.toBase58() }));
Expand All @@ -427,10 +455,13 @@ export async function startMatchOnchain(input: {
transaction.recentBlockhash = blockhash.blockhash;
transaction.lastValidBlockHeight = blockhash.lastValidBlockHeight;
const simulation = await connection.simulateTransaction(transaction);
if (simulation.value.err) {
const relevantLog = simulation.value.logs?.find((log) => /Error|failed|constraint|invalid|insufficient/i.test(log));
throw new Error(`start_match_simulation_failed: ${relevantLog ?? JSON.stringify(simulation.value.err)}`);
}
if (simulation.value.err) throw new Error(startMatchSimulationError(simulation.value, [
...(matchInfo.data.length < CURRENT_MATCH_BYTES ? ["migrate_legacy_match"] : []),
"start_match",
"initialize_escrow",
"prepare_rfq_round",
"initialize_match_result",
]));
const sent = await wallet.signAndSendTransaction(transaction);
const signature = typeof sent === "string" ? sent : sent.signature;
await connection.confirmTransaction({ signature, blockhash: blockhash.blockhash, lastValidBlockHeight: blockhash.lastValidBlockHeight }, "confirmed");
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/chain/matchState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ assert.equal(snapshot.taker, players[0].toBase58());
assert.equal(snapshot.playerCount, 4);
assert.equal(snapshot.roundCount, 3);
assert.deepEqual(snapshot.players, players.map((player) => player.toBase58()));
const authorityAfterAnotherPlayerJoined = players[1];
const authorityFirstSnapshotData = new Uint8Array(data);
authorityFirstSnapshotData.set(authorityAfterAnotherPlayerJoined.toBytes(), 8);
const authorityFirstSnapshot = decodePublicMatchAccount(match.toBase58(), authorityFirstSnapshotData);
assert.equal(authorityFirstSnapshot.host, authorityAfterAnotherPlayerJoined.toBase58());
assert.equal(authorityFirstSnapshot.taker, authorityAfterAnotherPlayerJoined.toBase58());
const resolvedData = new Uint8Array(data);
resolvedData[83] = 1;
resolvedData[374] = 0;
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/chain/matchState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ function readI64(data: Uint8Array, offset: number) {
return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigInt64(offset, true);
}

function takerForRound(players: string[], authority: string, currentRound: number) {
const hostIndex = players.indexOf(authority);
const startingIndex = hostIndex >= 0 ? hostIndex : 0;
return players[(startingIndex + currentRound) % players.length];
}

function assertAccount(data: Uint8Array, discriminator: Uint8Array, minimumLength: number, label: string) {
if (data.length < minimumLength || !sameBytes(data.slice(0, 8), discriminator)) throw new Error(`${label}_account_invalid`);
}
Expand Down Expand Up @@ -149,11 +155,9 @@ export function decodePublicMatchAccount(matchAddress: string, data: Uint8Array)
snapshot.lastRoundWinner = lastRoundWinner.toBase58();
}
}
snapshot.host = snapshot.authority;
if (snapshot.status === "STARTED" && snapshot.playerCount > 0) {
snapshot.host = snapshot.players[0];
snapshot.taker = snapshot.players[snapshot.currentRound % snapshot.playerCount];
} else if (snapshot.playerCount > 0) {
snapshot.host = snapshot.players[0];
snapshot.taker = takerForRound(snapshot.players, snapshot.authority, snapshot.currentRound);
}
return snapshot;
}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const configuredApiBaseUrl = import.meta.env.VITE_API_BASE_URL ||
import.meta.env.VITE_WORLD_HTTP ||
(import.meta.env.VITE_WORLD_WS || "ws://localhost:2567").replace(/^ws/, "http");

export const API_BASE_URL = configuredApiBaseUrl.replace(/\/+$/, "");
export const WORLD_WS_URL = import.meta.env.VITE_WORLD_WS || API_BASE_URL.replace(/^http/, "ws");
6 changes: 2 additions & 4 deletions apps/web/src/match/TradeIntentPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { parseTradeIntent, type TradeIntent } from "./tradeIntent";
import { API_BASE_URL } from "../config";

type Props = {
active: boolean;
Expand All @@ -11,9 +12,6 @@ type Props = {
submissionStatus?: string;
};

const worldHttp = import.meta.env.VITE_WORLD_HTTP ||
(import.meta.env.VITE_WORLD_WS || "ws://localhost:2567").replace(/^ws/, "http");

function speechError(reason: unknown) {
const detail = reason instanceof Error
? reason.message
Expand Down Expand Up @@ -135,7 +133,7 @@ export default function TradeIntentPanel({ active, matchId, role = "PLAYER", ses
}
setTranscribing(true);
try {
const response = await fetch(`${worldHttp}/api/speech/transcribe`, {
const response = await fetch(`${API_BASE_URL}/api/speech/transcribe`, {
method: "POST",
headers: {
"content-type": audio.type || "audio/webm",
Expand Down
62 changes: 61 additions & 1 deletion apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ body {
background: #0b0b0f;
}

.world-shell > div {
.world-shell > .world-game-mount {
position: absolute;
inset: 0;
}
Expand Down Expand Up @@ -139,6 +139,66 @@ body {
color: #ffb347;
}

.world-chat {
position: absolute;
z-index: 12;
right: 1rem;
bottom: 1rem;
display: grid;
gap: 0.35rem;
width: min(23rem, calc(100% - 2rem));
padding: 0.65rem;
color: #f4f1ea;
background: #11151df2;
border: 2px solid #2b3546;
box-shadow: 4px 4px 0 #0b0b0f;
font: 0.65rem monospace;
image-rendering: pixelated;
}

.world-chat label {
color: #ffb347;
letter-spacing: 0.12em;
font-weight: 700;
}

.world-chat-row {
display: flex;
gap: 0.4rem;
}

.world-chat input {
min-width: 0;
flex: 1;
padding: 0.6rem;
color: #0b0b0f;
background: #f4f1ea;
border: 2px solid #0b0b0f;
outline: 0;
font: 700 0.75rem monospace;
}

.world-chat input:focus {
border-color: #ffb347;
box-shadow: 2px 2px 0 #0b0b0f;
}

.world-chat button {
padding: 0.55rem 0.7rem;
border: 2px solid #0b0b0f;
box-shadow: 2px 2px 0 #0b0b0f;
font: 700 0.7rem monospace;
}

.world-chat button:disabled {
cursor: not-allowed;
opacity: 0.45;
}

.world-chat span {
color: #a9b3c6;
}

.pit-overlay {
position: absolute;
z-index: 20;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/world/PitOverlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ assert.match(source, /VITE_OUTCRY_MATCH_NONCE/);
assert.match(source, /nonce: currentMatchNonce/);
assert.match(source, /match_account_not_initialized/);
assert.match(source, /onChainSeatConflict/);
assert.match(source, /onWalletJoinStarted/);
assert.match(source, /awaiting_confirmation/);
assert.match(source, /Restoring that seat/);
assert.match(source, /Restore match to Base \(host\)/);
assert.match(source, /restoreDelegatedMatchOnchain/);
Expand Down
Loading
Loading