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
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { AuditsModule } from './audits/audits.module';
import { NotificationsModule } from './notifications/notifications.module';
import { GatewayModule } from './gateway/gateway.module';
import { AuditLogsModule } from './audit-logs/audit-logs.module';
import { StellarModule } from './stellar/stellar.module';
import { DepreciationModule } from './depreciation/depreciation.module';
import { ReservationsModule } from './reservations/reservations.module';

Expand Down Expand Up @@ -52,6 +53,7 @@ import { ReservationsModule } from './reservations/reservations.module';
NotificationsModule,
GatewayModule,
AuditLogsModule,
StellarModule,
DepreciationModule,
ReservationsModule,
TypeOrmModule.forRootAsync({
Expand Down
138 changes: 138 additions & 0 deletions backend/src/stellar/soroban.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

export interface StellarConfig {
network: 'testnet' | 'futurenet' | 'mainnet';
rpcUrl: string;
contractId: string;
enabled: boolean;
}

export interface ContractResult {
success: boolean;
txHash?: string;
ledger?: number;
error?: string;
}

@Injectable()
export class SorobanService implements OnModuleInit {
private readonly logger = new Logger(SorobanService.name);
private config: StellarConfig;

constructor(private readonly configService: ConfigService) {}

onModuleInit() {
const network = this.configService.get<string>(
'STELLAR_NETWORK',
'testnet',
) as StellarConfig['network'];
const rpcUrl = this.configService.get<string>(
'SOROBAN_RPC_URL',
network === 'testnet'
? 'https://soroban-testnet.stellar.org'
: network === 'futurenet'
? 'https://soroban-futurenet.stellar.org'
: 'https://soroban-mainnet.stellar.org',
);
const contractId = this.configService.get<string>(
'ASSETSUP_CONTRACT_ID',
'',
);
const secretKey = this.configService.get<string>('STELLAR_SECRET_KEY');

this.config = {
network,
rpcUrl,
contractId,
enabled: !!secretKey && !!contractId,
};

if (this.config.enabled) {
this.logger.log(
`SorobanService enabled — network: ${network}, contract: ${contractId}`,
);
} else {
this.logger.warn(
'SorobanService disabled — STELLAR_SECRET_KEY or ASSETSUP_CONTRACT_ID not set',
);
}
}

isEnabled(): boolean {
return this.config.enabled;
}

getConfig(): StellarConfig {
return { ...this.config };
}

async registerAsset(assetData: {
name: string;
assetTag: string;
description?: string;
category?: string;
}): Promise<ContractResult> {
if (!this.config.enabled) {
this.logger.debug('registerAsset called in disabled mode — no-op');
return { success: true };
}

try {
this.logger.log(
`Registering asset on-chain: ${assetData.assetTag} (${assetData.name})`,
);

// TODO: Implement actual Soroban contract invocation
// const contract = new Contract(this.config.contractId);
// const invocation = contract.call('register_asset', ...args);
// const result = await server.simulateTransaction(invocation);
// const tx = await server.sendTransaction(signedTx);
// const ledger = await server.waitForTransaction(tx.hash);

return {
success: true,
txHash: 'pending-implementation',
ledger: 0,
};
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
this.logger.error(`registerAsset failed: ${message}`);
return { success: false, error: message };
}
}

async getAsset(assetId: string): Promise<ContractResult & { data?: any }> {
if (!this.config.enabled) {
return { success: true, data: null };
}

try {
this.logger.log(`Reading asset from chain: ${assetId}`);

// TODO: Implement actual Soroban contract read
return { success: true, data: null };
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
this.logger.error(`getAsset failed: ${message}`);
return { success: false, error: message };
}
}

async healthCheck(): Promise<boolean> {
if (!this.config.enabled) {
return true;
}

try {
// TODO: Implement RPC health check
// const server = new SorobanRpc.Server(this.config.rpcUrl);
// await server.getHealth();
return true;
} catch {
return false;
}
}
}
9 changes: 9 additions & 0 deletions backend/src/stellar/stellar.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { SorobanService } from './soroban.service';

@Global()
@Module({
providers: [SorobanService],
exports: [SorobanService],
})
export class StellarModule {}
8 changes: 6 additions & 2 deletions frontend/components/layout/topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useRouter, usePathname } from "next/navigation";
import { useState, useRef, useEffect } from "react";
import { Menu, User, ChevronDown, Sun, Moon, Monitor } from "lucide-react";
import { useAuthStore } from "@/store/auth.store";
import { WalletButton } from "@/components/wallet/wallet-button";
import { useTheme } from "@/lib/theme-provider";

const pageTitles: Record<string, string> = {
Expand Down Expand Up @@ -78,8 +79,10 @@ export function Topbar({ onMenuClick, menuOpen }: TopbarProps) {
</h1>
</div>

{/* Right: user dropdown */}
<div className="relative" ref={dropdownRef}>
{/* Right: wallet button + user dropdown */}
<div className="flex items-center gap-3">
<WalletButton />
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen((v) => !v)}
aria-label="Open user menu"
Expand Down Expand Up @@ -135,6 +138,7 @@ export function Topbar({ onMenuClick, menuOpen }: TopbarProps) {
</div>
)}
</div>
</div>
</header>
);
}
100 changes: 100 additions & 0 deletions frontend/components/wallet/wallet-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"use client";

import { useWalletStore } from "@/store/wallet.store";
import { Wallet, ExternalLink, LogOut } from "lucide-react";
import { useState, useRef, useEffect } from "react";

function truncateAddress(address: string): string {
if (address.length <= 12) return address;
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}

export function WalletButton() {
const { isConnected, address, network, isConnecting, error, connect, disconnect } =
useWalletStore();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

useEffect(() => {
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);

if (!isConnected) {
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={connect}
disabled={isConnecting}
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
<Wallet size={15} />
{isConnecting ? "Connecting..." : "Connect Wallet"}
</button>
{error && (
<div className="absolute right-0 mt-2 w-72 p-3 bg-red-50 border border-red-200 rounded-lg shadow-md text-sm text-red-700 z-50">
{error}
<div className="mt-2">
<a
href="https://www.freighter.app/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-red-600 underline hover:text-red-800"
>
Install Freighter <ExternalLink size={12} />
</a>
</div>
</div>
)}
</div>
);
}

return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen((v) => !v)}
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-700 bg-green-50 hover:bg-green-100 rounded-lg transition-colors border border-green-200"
>
<div className="w-2 h-2 rounded-full bg-green-500" />
{truncateAddress(address!)}
</button>

{dropdownOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white border border-gray-200 rounded-lg shadow-md py-1 z-50">
<div className="px-4 py-2 border-b border-gray-100">
<p className="text-xs text-gray-500">Connected Wallet</p>
<p className="text-sm font-medium text-gray-900 truncate">
{address}
</p>
<p className="text-xs text-gray-400 mt-0.5">Network: {network}</p>
</div>
<a
href={`https://stellar.expert/explorer/${network}/account/${address}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<ExternalLink size={14} />
View on Stellar Expert
</a>
<button
onClick={() => {
disconnect();
setDropdownOpen(false);
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-gray-50"
>
<LogOut size={14} />
Disconnect
</button>
</div>
)}
</div>
);
}
Loading
Loading