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:
+217
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user