1
0
forked from maik/IPFS-portal

feat: initial IPFS Portal — Next.js frontend for remote IPFS node management

Multi-file upload with Quick Upload + Crypto Payment tabs.
Dashboard with live IPFS metrics, Explorer, Peers, Pins, History, Settings pages.
Docker deploy (standalone output), full test suite (16 Playwright tests).
Payment integration via IPFSPortalPayment + MockUSDC contracts on zkSync.
This commit is contained in:
maikrolf
2026-06-25 19:47:57 +02:00
commit 1ddc89479c
42 changed files with 8383 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
/* ── IPFS Portal API Layer ── */
const API_BASE = ''; // nginx proxy /api/* → backend
export interface IPFSNodeInfo {
version: string;
peerId: string;
peers: number;
storageUsed: string;
storageMax: string;
uptime: string;
}
export interface IPFSPin {
cid: string;
name: string;
size: number;
created: string;
}
export interface IPFSUser {
username: string;
created: string;
active: boolean;
}
/* ── Generic fetch wrapper ── */
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}/api${path}`, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
credentials: 'include',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 200)}`);
}
return res.json();
}
/* ── Node info ── */
export async function getNodeInfo(): Promise<IPFSNodeInfo> {
return apiFetch('/node/info');
}
export async function getPeers(): Promise<{ id: string; addr: string; latency: string }[]> {
const raw = await apiFetch<{ Peers?: { Peer: string; Addr: string; Latency: string }[] }>('/peers');
return (raw.Peers || []).map((p: { Peer: string; Addr: string; Latency: string }) => ({
id: p.Peer,
addr: p.Addr,
latency: p.Latency || '—',
}));
}
/* ── Storage ── */
export async function listPins(): Promise<IPFSPin[]> {
const raw = await apiFetch<{ Keys?: Record<string, { Type: string }> }>('/pins');
if (!raw.Keys) return [];
return Object.entries(raw.Keys).map(([cid, info]) => ({
cid,
name: '',
size: 0,
created: info.Type || '',
}));
}
export async function addPin(cid: string, name?: string): Promise<{ cid: string }> {
return apiFetch('/pins', {
method: 'POST',
body: JSON.stringify({ cid, name }),
});
}
export async function removePin(cid: string): Promise<void> {
await apiFetch(`/pins/${cid}`, { method: 'DELETE' });
}
/* ── Files ── */
export async function uploadFile(file: File): Promise<{ cid: string; size: number }> {
const form = new FormData();
form.append('file', file);
const res = await fetch(`${API_BASE}/api/files/upload`, {
method: 'POST',
body: form,
credentials: 'include',
});
if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`);
return res.json();
}
export async function listFiles(): Promise<{ name: string; cid: string; size: number; modified: string }[]> {
return apiFetch('/files');
}
/* ── Users (admin) ── */
export async function listUsers(): Promise<IPFSUser[]> {
const raw = await apiFetch<IPFSUser[] | { users: IPFSUser[] }>('/users');
// Handle both [{...}] and {users: [{...}]} response formats
if (Array.isArray(raw)) return raw;
if (raw && Array.isArray(raw.users)) return raw.users;
return [];
}
export async function createUser(username: string, password: string): Promise<{ username: string }> {
return apiFetch('/users', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
}
export async function deleteUser(username: string): Promise<void> {
await apiFetch(`/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
}
/* ── IPFS Explorer ── */
export interface IPFSEntry {
name: string;
type: 'file' | 'dir';
size: number;
hash: string;
}
export async function explorerLs(cid: string): Promise<IPFSEntry[]> {
return apiFetch(`/explorer/ls/${encodeURIComponent(cid)}`, { method: 'POST' });
}
export async function explorerCat(cid: string): Promise<string> {
const res = await fetch(`${API_BASE}/api/explorer/cat/${encodeURIComponent(cid)}`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 200)}`);
}
return res.text();
}
export async function explorerStat(cid: string): Promise<Record<string, unknown>> {
return apiFetch(`/explorer/stat/${encodeURIComponent(cid)}`, { method: 'POST' });
}
/* ── IPNS ── */
export interface IPNSKey {
name: string;
id: string;
}
export async function listIPNSKeys(): Promise<IPNSKey[]> {
const res = await apiFetch<{ Keys: IPNSKey[] }>('/ipns/keys');
return res.Keys || [];
}
export async function ipnsPublish(cid: string, key = 'self'): Promise<{ name: string; value: string }> {
return apiFetch(`/ipns/publish?arg=${encodeURIComponent(cid)}&key=${encodeURIComponent(key)}`, {
method: 'POST',
});
}
export async function ipnsResolve(name: string): Promise<string> {
const res = await apiFetch<{ Path: string }>(`/ipns/resolve/${encodeURIComponent(name)}`);
return res.Path || '';
}
export async function ipnsGenKey(name: string): Promise<IPNSKey> {
return apiFetch(`/ipns/keys/gen/${encodeURIComponent(name)}`, { method: 'POST' });
}
/* ── Repo / Bandwidth Stats ── */
export interface RepoStats {
repoSize: number;
storageMax: number;
numObjects: number;
repoPath: string;
version: string;
}
export interface BWStats {
totalIn: number;
totalOut: number;
rateIn: number;
rateOut: number;
}
export async function getRepoStats(): Promise<RepoStats> {
const raw = await apiFetch<{
RepoSize?: number; StorageMax?: number; NumObjects?: number;
RepoPath?: string; Version?: string;
}>('/repo/stats');
return {
repoSize: raw.RepoSize ?? 0,
storageMax: raw.StorageMax ?? 0,
numObjects: raw.NumObjects ?? 0,
repoPath: raw.RepoPath ?? '',
version: raw.Version ?? '',
};
}
export async function getBWStats(): Promise<BWStats> {
const raw = await apiFetch<{
TotalIn?: number; TotalOut?: number; RateIn?: number; RateOut?: number;
}>('/bw/stats');
return {
totalIn: raw.TotalIn ?? 0,
totalOut: raw.TotalOut ?? 0,
rateIn: raw.RateIn ?? 0,
rateOut: raw.RateOut ?? 0,
};
}
/* ── Health ── */
export async function checkHealth(): Promise<{ status: string; node: string }> {
return apiFetch('/health');
}
+539
View File
@@ -0,0 +1,539 @@
/* ── IPFS Portal Payment Service ── */
import {
createWalletClient,
createPublicClient,
custom,
http,
encodeFunctionData,
type Chain,
type Address,
type Hash,
} from 'viem';
import { getInjectedProvider, type WalletProvider, type WalletState } from './wallet';
/* ── Chain 270 (zkSync Local) ── */
export const zkSyncLocal: Chain = {
id: 270,
name: 'zkSync Local',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: {
default: { http: ['http://tom1687.no-ip.org:3050'] },
},
blockExplorers: {
default: { name: 'Explorer', url: 'http://tom1687.no-ip.org:3050' },
},
};
/* ── Contract ABI ── */
export const IPFS_PORTAL_PAYMENT_ABI = [
// ── State ──
{ type: 'function', name: 'owner', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' },
{ type: 'function', name: 'maosTokenAddress', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' },
{ type: 'function', name: 'freeTierBytes', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
{ type: 'function', name: 'basePricePerMB', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
{ type: 'function', name: 'userTotalBytes', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
{ type: 'function', name: 'totalRevenue', inputs: [{ type: 'string' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
{ type: 'function', name: 'totalUploads', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
{ type: 'function', name: 'supportedTokens', inputs: [{ type: 'uint256' }], outputs: [{ type: 'string' }], stateMutability: 'view' },
// ── View ──
{ type: 'function', name: 'getPrice', inputs: [{ type: 'uint256' }, { type: 'address' }], outputs: [
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'bool' }
], stateMutability: 'view' },
{ type: 'function', name: 'getUserStats', inputs: [{ type: 'address' }], outputs: [
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' },
{ type: 'tuple[]', components: [
{ type: 'string' }, { type: 'uint256' }, { type: 'string' }, { type: 'uint256' }, { type: 'uint256' }
]}
], stateMutability: 'view' },
{ type: 'function', name: 'getMAOSDiscountTiers', inputs: [], outputs: [
{ type: 'tuple[]', components: [{ type: 'uint256' }, { type: 'uint256' }] }
], stateMutability: 'view' },
{ type: 'function', name: 'getSupportedTokens', inputs: [], outputs: [{ type: 'string[]' }], stateMutability: 'view' },
{ type: 'function', name: 'tokenConfigs', inputs: [{ type: 'string' }], outputs: [
{ type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'bool' }
], stateMutability: 'view' },
// ── Write ──
{ type: 'function', name: 'payWithETH', inputs: [{ type: 'string' }, { type: 'uint256' }], outputs: [], stateMutability: 'payable' },
{ type: 'function', name: 'payWithToken', inputs: [{ type: 'string' }, { type: 'uint256' }, { type: 'string' }], outputs: [], stateMutability: 'nonpayable' },
// ── Admin Write ──
{ type: 'function', name: 'setPricePerMB', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
{ type: 'function', name: 'setFreeTierBytes', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
{ type: 'function', name: 'setMAOSDiscountTier', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
{ type: 'function', name: 'removeMAOSDiscountTier', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
{ type: 'function', name: 'configureToken', inputs: [{ type: 'string' }, { type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'bool' }], outputs: [], stateMutability: 'nonpayable' },
{ type: 'function', name: 'setMAOSTokenAddress', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
{ type: 'function', name: 'withdrawETH', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
{ type: 'function', name: 'withdrawToken', inputs: [{ type: 'string' }, { type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
{ type: 'function', name: 'transferOwnership', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
// ── Events ──
{ type: 'event', name: 'UploadPaidETH', inputs: [
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }
]},
{ type: 'event', name: 'UploadPaidToken', inputs: [
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
{ type: 'uint256' }, { type: 'string', indexed: true }, { type: 'uint256' }, { type: 'uint256' }
]},
{ type: 'event', name: 'FreeUploadUsed', inputs: [
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
{ type: 'uint256' }, { type: 'uint256' }
]},
] as const;
/* ── Default contract address (set after deploy) ── */
export const DEFAULT_CONTRACT_ADDRESS = '0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe';
/* ── ERC20 ABI (minimal for approve + balanceOf) ── */
const ERC20_ABI = [
{ type: 'function', name: 'balanceOf', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
{ type: 'function', name: 'approve', inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'bool' }], stateMutability: 'nonpayable' },
{ type: 'function', name: 'allowance', inputs: [{ type: 'address' }, { type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
{ type: 'function', name: 'decimals', inputs: [], outputs: [{ type: 'uint8' }], stateMutability: 'view' },
] as const;
/* ── Types ── */
export interface PriceInfo {
totalWei: bigint;
discountBps: number;
finalWei: bigint;
isFree: boolean;
}
export interface UserUploadStats {
totalBytes: bigint;
uploadCount: number;
remainingFree: bigint;
}
export interface TokenInfo {
symbol: string;
address: Address;
decimals: number;
weiPerToken: bigint;
enabled: boolean;
}
export interface MAOSDiscountTier {
minBalance: bigint;
discountBps: number;
}
export interface UploadRecord {
cid: string;
bytesSize: bigint;
tokenSymbol: string;
amountPaid: bigint;
timestamp: bigint;
}
export interface UserFullStats {
totalBytes: bigint;
uploadCount: number;
remainingFree: bigint;
uploads: UploadRecord[];
}
/* ── PaymentService ── */
export class PaymentService {
private contractAddress: Address;
constructor(contractAddress?: string) {
this.contractAddress = (contractAddress || DEFAULT_CONTRACT_ADDRESS) as Address;
}
setContractAddress(address: string) {
this.contractAddress = address as Address;
}
/* ── Create public client (read-only) ── */
private getPublicClient() {
return createPublicClient({
chain: zkSyncLocal,
transport: http(),
});
}
/* ── Create wallet client from provider ── */
private getWalletClient(provider: WalletProvider) {
return createWalletClient({
chain: zkSyncLocal,
transport: custom(provider),
});
}
/* ── READ: Get price for upload ── */
async getPrice(bytesSize: number, userAddress: Address): Promise<PriceInfo> {
const client = this.getPublicClient();
const result = await client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'getPrice',
args: [BigInt(bytesSize), userAddress],
});
return {
totalWei: result[0] as bigint,
discountBps: Number(result[1]),
finalWei: result[2] as bigint,
isFree: result[3] as boolean,
};
}
/* ── READ: Get user upload stats ── */
async getUserStats(userAddress: Address): Promise<UserUploadStats> {
const client = this.getPublicClient();
const result = await client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'getUserStats',
args: [userAddress],
});
return {
totalBytes: result[0] as bigint,
uploadCount: Number(result[1]),
remainingFree: result[2] as bigint,
};
}
/* ── READ: Get full user stats including upload records ── */
async getUserUploads(userAddress: Address): Promise<UserFullStats> {
const client = this.getPublicClient();
const result = await client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'getUserStats',
args: [userAddress],
}) as unknown as readonly [bigint, bigint, bigint, readonly (readonly [string, bigint, string, bigint, bigint])[]];
const records = result[3] as unknown as readonly (readonly [string, bigint, string, bigint, bigint])[] ?? [];
const uploads: UploadRecord[] = Array.from(records).map((rec) => ({
cid: rec[0],
bytesSize: rec[1],
tokenSymbol: rec[2],
amountPaid: rec[3],
timestamp: rec[4],
}));
return {
totalBytes: result[0],
uploadCount: Number(result[1]),
remainingFree: result[2],
uploads,
};
}
/* ── READ: Get supported tokens ── */
async getSupportedTokens(): Promise<TokenInfo[]> {
const client = this.getPublicClient();
const symbols = await client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'getSupportedTokens',
args: [],
}) as string[];
const tokens: TokenInfo[] = [];
for (const symbol of symbols) {
const config = await client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'tokenConfigs',
args: [symbol],
});
tokens.push({
symbol,
address: (config as any)[0] as Address,
decimals: (config as any)[1] as number,
weiPerToken: (config as any)[2] as bigint,
enabled: (config as any)[3] as boolean,
});
}
return tokens;
}
/* ── READ: Get MAOS discount tiers ── */
async getMAOSDiscountTiers(): Promise<MAOSDiscountTier[]> {
const client = this.getPublicClient();
const result = await client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'getMAOSDiscountTiers',
args: [],
}) as unknown;
const tiers = result as readonly (readonly [bigint, bigint])[];
return Array.from(tiers).map(([minBalance, discountBps]) => ({
minBalance,
discountBps: Number(discountBps),
}));
}
/* ── READ: Get free tier bytes ── */
async getFreeTierBytes(): Promise<bigint> {
const client = this.getPublicClient();
return client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'freeTierBytes',
args: [],
}) as Promise<bigint>;
}
/* ── READ: Get base price per MB ── */
async getBasePricePerMB(): Promise<bigint> {
const client = this.getPublicClient();
return client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'basePricePerMB',
args: [],
}) as Promise<bigint>;
}
/* ── WRITE: Pay with ETH ── */
async payWithETH(
provider: WalletProvider,
cid: string,
bytesSize: number,
priceWei: bigint,
): Promise<Hash> {
const walletClient = this.getWalletClient(provider);
const [address] = await walletClient.getAddresses();
const hash = await walletClient.sendTransaction({
account: address,
to: this.contractAddress,
value: priceWei,
data: this.encodePayWithETH(cid, bytesSize),
});
return hash;
}
/* ── WRITE: Approve ERC20 + Pay with token ── */
async approveAndPayWithToken(
provider: WalletProvider,
tokenAddress: Address,
cid: string,
bytesSize: number,
tokenAmount: bigint,
tokenSymbol: string,
): Promise<{ approveHash?: Hash; payHash: Hash }> {
const walletClient = this.getWalletClient(provider);
const [address] = await walletClient.getAddresses();
// Check allowance
const publicClient = this.getPublicClient();
const allowance = await publicClient.readContract({
address: tokenAddress,
abi: ERC20_ABI,
functionName: 'allowance',
args: [address, this.contractAddress],
}) as bigint;
let approveHash: Hash | undefined;
if (allowance < tokenAmount) {
const approveReq = await walletClient.writeContract({
account: address,
address: tokenAddress,
abi: ERC20_ABI,
functionName: 'approve',
args: [this.contractAddress, tokenAmount],
});
approveHash = approveReq;
}
// Pay
const payHash = await walletClient.writeContract({
account: address,
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'payWithToken',
args: [cid, BigInt(bytesSize), tokenSymbol],
});
return { approveHash, payHash };
}
/* ── Encode payWithETH data ── */
private encodePayWithETH(cid: string, bytesSize: number): `0x${string}` {
return encodeFunctionData({
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'payWithETH',
args: [cid, BigInt(bytesSize)],
});
}
/* ── READ: Get contract owner ── */
async getOwner(): Promise<Address> {
const client = this.getPublicClient();
return client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'owner',
args: [],
}) as Promise<Address>;
}
/* ── READ: Get total revenue for a token ── */
async getTotalRevenue(tokenSymbol: string): Promise<bigint> {
const client = this.getPublicClient();
return client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'totalRevenue',
args: [tokenSymbol],
}) as Promise<bigint>;
}
/* ── READ: Get total upload count ── */
async getTotalUploads(): Promise<bigint> {
const client = this.getPublicClient();
return client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'totalUploads',
args: [],
}) as Promise<bigint>;
}
/* ── READ: Get supported token symbols (string[]) ── */
async getTokenSymbols(): Promise<string[]> {
const client = this.getPublicClient();
return client.readContract({
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'getSupportedTokens',
args: [],
}) as Promise<string[]>;
}
/* ── WRITE: Admin — set price per MB (onlyOwner) ── */
async adminSetPricePerMB(provider: WalletProvider, priceWei: bigint): Promise<Hash> {
const walletClient = this.getWalletClient(provider);
const [address] = await walletClient.getAddresses();
return walletClient.writeContract({
account: address,
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'setPricePerMB',
args: [priceWei],
});
}
/* ── WRITE: Admin — set free tier bytes (onlyOwner) ── */
async adminSetFreeTierBytes(provider: WalletProvider, bytes: bigint): Promise<Hash> {
const walletClient = this.getWalletClient(provider);
const [address] = await walletClient.getAddresses();
return walletClient.writeContract({
account: address,
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'setFreeTierBytes',
args: [bytes],
});
}
/* ── WRITE: Admin — add/update MAOS discount tier ── */
async adminSetDiscountTier(provider: WalletProvider, minBalance: bigint, discountBps: number): Promise<Hash> {
const walletClient = this.getWalletClient(provider);
const [address] = await walletClient.getAddresses();
return walletClient.writeContract({
account: address,
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'setMAOSDiscountTier',
args: [minBalance, BigInt(discountBps)],
});
}
/* ── WRITE: Admin — remove MAOS discount tier ── */
async adminRemoveDiscountTier(provider: WalletProvider, index: number): Promise<Hash> {
const walletClient = this.getWalletClient(provider);
const [address] = await walletClient.getAddresses();
return walletClient.writeContract({
account: address,
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'removeMAOSDiscountTier',
args: [BigInt(index)],
});
}
/* ── WRITE: Admin — configure token ── */
async adminConfigureToken(
provider: WalletProvider,
symbol: string,
tokenAddress: Address,
decimals: number,
weiPerToken: bigint,
enabled: boolean,
): Promise<Hash> {
const walletClient = this.getWalletClient(provider);
const [address] = await walletClient.getAddresses();
return walletClient.writeContract({
account: address,
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'configureToken',
args: [symbol, tokenAddress, decimals, weiPerToken, enabled],
});
}
/* ── WRITE: Admin — withdraw ETH (onlyOwner) ── */
async adminWithdrawETH(provider: WalletProvider, to: Address): Promise<Hash> {
const walletClient = this.getWalletClient(provider);
const [address] = await walletClient.getAddresses();
return walletClient.writeContract({
account: address,
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'withdrawETH',
args: [to],
});
}
/* ── WRITE: Admin — withdraw token (onlyOwner) ── */
async adminWithdrawToken(provider: WalletProvider, tokenSymbol: string, to: Address): Promise<Hash> {
const walletClient = this.getWalletClient(provider);
const [address] = await walletClient.getAddresses();
return walletClient.writeContract({
account: address,
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'withdrawToken',
args: [tokenSymbol, to],
});
}
/* ── WRITE: Admin — transfer ownership ── */
async adminTransferOwnership(provider: WalletProvider, newOwner: Address): Promise<Hash> {
const walletClient = this.getWalletClient(provider);
const [address] = await walletClient.getAddresses();
return walletClient.writeContract({
account: address,
address: this.contractAddress,
abi: IPFS_PORTAL_PAYMENT_ABI,
functionName: 'transferOwnership',
args: [newOwner],
});
}
/* ── Check if contract is deployed ── */
async isDeployed(): Promise<boolean> {
if (this.contractAddress === DEFAULT_CONTRACT_ADDRESS) return false;
try {
const client = this.getPublicClient();
const code = await client.getBytecode({ address: this.contractAddress });
return code !== undefined && code !== '0x';
} catch { return false; }
}
}
/* ── Singleton ── */
export const paymentService = new PaymentService();
+98
View File
@@ -0,0 +1,98 @@
/* ── Client-side storage for IPFS Portal ──
*
* PortalSettings — editable config (gateway, API, storage max, refresh, theme)
* UploadRecord — persisted upload history (auto-saved after each upload)
*/
/* ════════════════════════════ Types ════════════════════════════ */
export interface PortalSettings {
gatewayUrl: string;
apiEndpoint: string;
storageMax: number; // MB
refreshInterval: number; // seconds (dashboard auto-refresh)
theme: 'dark' | 'light';
}
export interface UploadRecord {
cid: string;
name: string;
size: number;
date: string; // ISO string
method: 'free' | 'eth' | 'token' | 'quick';
txHash?: string;
}
/* ════════════════════════════ Defaults ════════════════════════════ */
const STORAGE_KEY = 'ipfs-portal-settings';
const HISTORY_KEY = 'ipfs-portal-history';
const DEFAULT_SETTINGS: PortalSettings = {
gatewayUrl: 'https://maos.dedyn.io/ipfs',
apiEndpoint: '',
storageMax: 30720, // 30 GB
refreshInterval: 15,
theme: 'dark',
};
/* ════════════════════════════ Settings ════════════════════════════ */
export function getSettings(): PortalSettings {
if (typeof window === 'undefined') return { ...DEFAULT_SETTINGS };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export function saveSettings(partial: Partial<PortalSettings>): PortalSettings {
const current = getSettings();
const merged = { ...current, ...partial };
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
} catch { /* quota exceeded — silently degrade */ }
return merged;
}
export function resetSettings(): PortalSettings {
try {
localStorage.removeItem(STORAGE_KEY);
} catch { /* ignore */ }
return { ...DEFAULT_SETTINGS };
}
/* ════════════════════════════ History ════════════════════════════ */
export function getHistory(): UploadRecord[] {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(HISTORY_KEY);
if (!raw) return [];
return JSON.parse(raw) as UploadRecord[];
} catch {
return [];
}
}
export function addToHistory(record: UploadRecord): UploadRecord[] {
const history = getHistory();
// Deduplicate by CID + name
const filtered = history.filter(h => !(h.cid === record.cid && h.name === record.name));
filtered.unshift(record);
// Keep max 500 entries
const trimmed = filtered.slice(0, 500);
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed));
} catch { /* ignore */ }
return trimmed;
}
export function clearHistory(): void {
try {
localStorage.removeItem(HISTORY_KEY);
} catch { /* ignore */ }
}
+196
View File
@@ -0,0 +1,196 @@
/* ── Wallet connection (EIP-6963 + MAOS extension) ── */
/* ── Types ── */
export interface WalletProvider {
request: (args: { method: string; params?: unknown[] }) => Promise<any>;
on?: (event: string, cb: (...args: any[]) => void) => void;
removeListener?: (event: string, cb: (...args: any[]) => void) => void;
}
export interface WalletState {
address: string;
chainId: number;
isConnected: boolean;
isConnecting: boolean;
walletType: 'metamask' | 'maos' | 'rabby' | 'other' | null;
}
export interface DetectedWallet {
name: string;
provider: WalletProvider;
icon?: string;
isMAOS: boolean;
}
/* ── Chain IDs ── */
export const CHAIN_IDS = {
ZKSYNC_LOCAL: 270,
ZKSYNC_ERA: 324,
ETHEREUM: 1,
SEPOLIA: 11155111,
} as const;
/* ── Chain 270 params for wallet_addEthereumChain ── */
const ZKSYNC_LOCAL_CHAIN = {
chainId: '0x10E', // 270
chainName: 'zkSync Local',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['http://tom1687.no-ip.org:3050'],
blockExplorerUrls: ['http://tom1687.no-ip.org:3050'],
};
/* ── EIP-6963 event-based provider discovery ── */
export function discoverWallets(): Promise<DetectedWallet[]> {
return new Promise((resolve) => {
const wallets: DetectedWallet[] = [];
const timeout = setTimeout(() => resolve(wallets), 2000);
function handleAnnounce(e: CustomEvent) {
const detail = e.detail;
if (detail?.provider) {
const info = detail.info || {};
const isMAOS = !!(info.name?.toLowerCase().includes('maos') || detail.provider.isMAOSv6);
// Avoid duplicates
if (!wallets.some(w => w.provider === detail.provider)) {
wallets.push({
name: info.name || (isMAOS ? 'MAOS Wallet' : 'Unknown Wallet'),
provider: detail.provider,
icon: info.icon,
isMAOS,
});
}
}
}
window.addEventListener('eip6963:announceProvider', handleAnnounce as EventListener);
// Request announcement
window.dispatchEvent(new CustomEvent('eip6963:requestProvider'));
// Also check legacy window.ethereum
setTimeout(() => {
// @ts-expect-error - window.ethereum
const legacy = window.ethereum as WalletProvider | undefined;
if (legacy && !wallets.some(w => w.provider === legacy)) {
// @ts-expect-error
const isMetaMask = window.ethereum?.isMetaMask === true;
// @ts-expect-error - isMAOSv6
const isMAOS = window.maosv6 !== undefined || window.ethereum?.isMAOSv6 === true;
wallets.push({
name: isMAOS ? 'MAOS Wallet' : isMetaMask ? 'MetaMask' : 'Wallet',
provider: legacy,
isMAOS: !!isMAOS,
});
}
clearTimeout(timeout);
resolve(wallets);
}, 500);
});
}
/* ── Get best provider (MAOS preferred if available) ── */
export function getInjectedProvider(): WalletProvider | null {
if (typeof window === 'undefined') return null;
// @ts-expect-error - window.maosv6
if (window.maosv6) return window.maosv6 as WalletProvider;
// @ts-expect-error - window.ethereum
if (window.ethereum) return window.ethereum as WalletProvider;
return null;
}
/* ── Detect wallet type ── */
export function detectWalletType(): 'maos' | 'metamask' | 'rabby' | 'other' | null {
if (typeof window === 'undefined') return null;
// @ts-expect-error
if (window.maosv6) return 'maos';
// @ts-expect-error
if (window.ethereum?.isRabby) return 'rabby';
// @ts-expect-error
if (window.ethereum?.isMetaMask) return 'metamask';
// @ts-expect-error
if (window.ethereum) return 'other';
return null;
}
/* ── Connect wallet ── */
export async function connectWallet(provider?: WalletProvider): Promise<{ address: string; chainId: number }> {
const p = provider || getInjectedProvider();
if (!p) throw new Error('No wallet found. Install MetaMask, Rabby, or MAOS extension.');
const accounts: string[] = await p.request({ method: 'eth_requestAccounts' });
const chainIdHex: string = await p.request({ method: 'eth_chainId' });
return {
address: accounts[0],
chainId: parseInt(chainIdHex, 16),
};
}
/* ── Switch to chain 270 (zkSync Local) ── */
export async function switchToChain270(provider?: WalletProvider): Promise<boolean> {
const p = provider || getInjectedProvider();
if (!p) throw new Error('No wallet found');
try {
await p.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: '0x10E' }],
});
return true;
} catch (e: any) {
// 4902 = chain not added yet
if (e.code === 4902) {
try {
await p.request({
method: 'wallet_addEthereumChain',
params: [ZKSYNC_LOCAL_CHAIN],
});
return true;
} catch {
return false;
}
}
return false;
}
}
/* ── Sign message ── */
export async function signMessage(
provider: WalletProvider,
message: string,
address: string,
): Promise<string> {
return provider.request({
method: 'personal_sign',
params: [message, address],
});
}
/* ── Get ETH balance ── */
export async function getBalance(address: string): Promise<string> {
if (typeof window === 'undefined') return '0';
const provider = getInjectedProvider();
if (!provider) return '0';
const balanceHex: string = await provider.request({
method: 'eth_getBalance',
params: [address, 'latest'],
});
return BigInt(balanceHex).toString();
}
/* ── Format wei to ETH string ── */
export function formatWeiToETH(wei: bigint | string, decimals = 4): string {
const value = typeof wei === 'string' ? BigInt(wei) : wei;
const eth = Number(value) / 1e18;
return eth.toFixed(decimals);
}
/* ── Format bytes to human-readable ── */
export function formatBytes(bytes: bigint | number): string {
const b = typeof bytes === 'bigint' ? Number(bytes) : bytes;
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`;
return `${(b / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}