diff --git a/soroban-client/lib/checkin.ts b/soroban-client/lib/checkin.ts new file mode 100644 index 00000000..57a30f4f --- /dev/null +++ b/soroban-client/lib/checkin.ts @@ -0,0 +1,94 @@ +export interface CheckInParams { + organizer: string; + eventId: number; + tokenId: number; +} + +export interface CheckInResult { + success: boolean; + timestamp: number; + transactionHash: string; +} + +export async function checkInEvent(params: CheckInParams): Promise { + const EVENT_MANAGER_CONTRACT = + process.env.NEXT_PUBLIC_EVENT_MANAGER_CONTRACT || ""; + const HORIZON_URL = + process.env.NEXT_PUBLIC_HORIZON_URL || "https://horizon-testnet.stellar.org"; + const NETWORK_PASSPHRASE = + process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015"; + + if (!EVENT_MANAGER_CONTRACT || EVENT_MANAGER_CONTRACT === "") { + throw new Error( + "EVENT_MANAGER_CONTRACT is not configured. Set NEXT_PUBLIC_EVENT_MANAGER_CONTRACT in your env." + ); + } + + const { Server, TransactionBuilder, Operation, Networks } = await import("@stellar/stellar-sdk"); + const { nativeToScVal } = await import("@stellar/stellar-base"); + const { signTransaction } = await import("@stellar/freighter-api"); + + const server = new Server(HORIZON_URL); + const sourceAccount = await server.loadAccount(params.organizer); + const fee = await server.fetchBaseFee(); + + const args = [ + nativeToScVal(params.eventId, { type: "u32" }), + nativeToScVal(params.tokenId, { type: "u32" }), + ]; + + const operation = Operation.invokeContractFunction({ + contract: EVENT_MANAGER_CONTRACT, + function: "check_in", + args, + }); + + const tx = new TransactionBuilder(sourceAccount, { + fee: fee.toString(), + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const txXdr = tx.toXDR(); + const { signedTxXdr } = await signTransaction(txXdr, { + networkPassphrase: NETWORK_PASSPHRASE, + address: params.organizer, + }); + + const response = await server.submitTransaction(signedTxXdr); + + return { + success: true, + timestamp: Math.floor(Date.now() / 1000), + transactionHash: response.hash, + }; +} + +export async function verifyCheckIn( + eventId: number, + tokenId: number +): Promise { + const EVENT_MANAGER_CONTRACT = + process.env.NEXT_PUBLIC_EVENT_MANAGER_CONTRACT || ""; + const HORIZON_URL = + process.env.NEXT_PUBLIC_HORIZON_URL || "https://horizon-testnet.stellar.org"; + + if (!EVENT_MANAGER_CONTRACT || EVENT_MANAGER_CONTRACT === "") { + throw new Error("EVENT_MANAGER_CONTRACT is not configured"); + } + + const server = new Server(HORIZON_URL); + + try { + const result = await server.getContractEvents({ + contract: EVENT_MANAGER_CONTRACT, + topic: [["check_in", eventId, tokenId]], + }); + + return result.events.length > 0; + } catch { + return false; + } +} \ No newline at end of file diff --git a/soroban-client/lib/events/websocket.ts b/soroban-client/lib/events/websocket.ts new file mode 100644 index 00000000..f6d7a631 --- /dev/null +++ b/soroban-client/lib/events/websocket.ts @@ -0,0 +1,106 @@ +export interface EventListenerOptions { + eventContractId: string; + startCursor?: string; + onEvent: (event: ContractEvent) => void; + onError?: (error: Error) => void; +} + +export interface ContractEvent { + id: string; + contractId: string; + type: string; + data: Record; + timestamp: number; + cursor: string; +} + +export type ConnectionStatus = "connecting" | "connected" | "disconnecting" | "disconnected" | "error"; + +export class EventMonitor { + private ws: WebSocket | null = null; + private status: ConnectionStatus = "disconnected"; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + private options: EventListenerOptions | null = null; + + async connect(options: EventListenerOptions): Promise { + this.options = options; + this.status = "connecting"; + + const wsUrl = this.buildWsUrl(options.eventContractId, options.startCursor); + + try { + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.status = "connected"; + this.reconnectAttempts = 0; + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + const contractEvent: ContractEvent = { + id: data.id, + contractId: data.contractId || options.eventContractId, + type: data.type || data.topic || "unknown", + data: data.data || {}, + timestamp: data.timestamp || Date.now(), + cursor: data.cursor || "", + }; + options.onEvent(contractEvent); + } catch (e) { + console.error("Failed to parse WebSocket message:", e); + } + }; + + this.ws.onerror = () => { + this.status = "error"; + options.onError?.(new Error("WebSocket connection error")); + }; + + this.ws.onclose = () => { + this.status = "disconnected"; + this.attemptReconnect(); + }; + } catch (e) { + this.status = "error"; + throw e; + } + } + + private buildWsUrl(contractId: string, cursor?: string): string { + const horizonUrl = process.env.NEXT_PUBLIC_HORIZON_URL || "https://horizon-testnet.stellar.org"; + const protocol = horizonUrl.includes("testnet") ? "wss" : "wss"; + return `${protocol}${ + horizonUrl.replace("https://", "").replace("http://", "") + }/events?contractId=${contractId}${cursor ? `&cursor=${cursor}` : ""}`; + } + + private attemptReconnect(): void { + if (this.options && this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + setTimeout(() => { + this.options && this.connect(this.options); + }, this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)); + } + } + + disconnect(): void { + if (this.ws) { + this.status = "disconnecting"; + this.ws.close(); + this.ws = null; + } + this.status = "disconnected"; + } + + getStatus(): ConnectionStatus { + return this.status; + } +} + +export function createEventMonitor(): EventMonitor { + return new EventMonitor(); +} \ No newline at end of file diff --git a/soroban-client/lib/pagination.ts b/soroban-client/lib/pagination.ts new file mode 100644 index 00000000..3a02160a --- /dev/null +++ b/soroban-client/lib/pagination.ts @@ -0,0 +1,45 @@ +export interface PaginationParams { + cursor?: string; + limit?: number; +} + +export interface PaginatedResult { + data: T[]; + nextCursor: string | null; + hasMore: boolean; + total?: number; +} + +export function createPaginationParams( + cursor?: string, + limit: number = 10 +): PaginationParams { + return { + cursor, + limit: Math.min(Math.max(1, limit), 100), + }; +} + +export function parsePaginationResponse( + data: T[], + total?: number +): PaginatedResult { + return { + data, + nextCursor: data.length > 0 ? encodeCursor(data[data.length - 1]) : null, + hasMore: data.length > 0, + total, + }; +} + +function encodeCursor(item: unknown): string { + return Buffer.from(JSON.stringify(item)).toString("base64"); +} + +export function decodeCursor(cursor: string): T | null { + try { + return JSON.parse(Buffer.from(cursor, "base64").toString()) as T; + } catch { + return null; + } +} \ No newline at end of file diff --git a/soroban-client/lib/wallet/adapter.ts b/soroban-client/lib/wallet/adapter.ts new file mode 100644 index 00000000..86cc2cd5 --- /dev/null +++ b/soroban-client/lib/wallet/adapter.ts @@ -0,0 +1,18 @@ +export interface WalletAdapter { + isConnected: boolean; + isInstalled: boolean; + address: string | null; + connect(): Promise; + disconnect(): void; + signTransaction(txXdr: string, options?: SignOptions): Promise; +} + +export interface SignOptions { + networkPassphrase: string; + address: string; +} + +export interface WalletConfig { + networkPassphrase: string; + horizonUrl: string; +} \ No newline at end of file