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
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,26 @@ ETHEREUM_RPC_URL=
# that route respond with ERR_PAYMENT_NOT_CONFIGURED instead of a fabricated
# result.
EVM_SOURCE_PRIVATE_KEY=

# ── Solana (devnet by default) — Issue #142 ───────────────────────────────
# Optional RPC override. Public default (api.devnet.solana.com) is used
# when unset — fine for light development use.
SOLANA_RPC_URL=

# Server-only base58 secret key for the account that signs and submits real
# `/api/solana/send` transactions, mirroring EVM_SOURCE_PRIVATE_KEY above.
# NEVER commit a real value. Leave unset to have that route respond with
# ERR_PAYMENT_NOT_CONFIGURED instead of a fabricated result.
SOLANA_SOURCE_PRIVATE_KEY=

# ── Sui (testnet by default) — Issue #143 ─────────────────────────────────
# Optional RPC override. Public default (fullnode.testnet.sui.io) is used
# when unset — fine for light development use.
SUI_RPC_URL=

# Server-only bech32 "suiprivkey1..." secret key for the account that signs
# and submits real `/api/sui/send` transactions, mirroring
# EVM_SOURCE_PRIVATE_KEY above. NEVER commit a real value. Leave unset to
# have that route respond with ERR_PAYMENT_NOT_CONFIGURED instead of a
# fabricated result.
SUI_SOURCE_PRIVATE_KEY=
10 changes: 10 additions & 0 deletions app/[locale]/ledger/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { AppShell } from "@/components/app/app-shell"
import { LedgerSendCard } from "@/components/app/ledger-send-card"

export default function LedgerPage() {
return (
<AppShell>
<LedgerSendCard />
</AppShell>
)
}
10 changes: 10 additions & 0 deletions app/[locale]/solana/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { AppShell } from "@/components/app/app-shell"
import { SolanaWalletCard } from "@/components/app/solana-wallet-card"

export default function SolanaPage() {
return (
<AppShell>
<SolanaWalletCard />
</AppShell>
)
}
10 changes: 10 additions & 0 deletions app/[locale]/sui/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { AppShell } from "@/components/app/app-shell"
import { SuiWalletCard } from "@/components/app/sui-wallet-card"

export default function SuiPage() {
return (
<AppShell>
<SuiWalletCard />
</AppShell>
)
}
43 changes: 43 additions & 0 deletions app/api/solana/balance/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* GET /api/solana/balance?address=...
*
* Real, read-only balance query against Solana devnet RPC — no signing key
* required. See lib/solana/solana.service.ts.
*/

import { NextRequest, NextResponse } from 'next/server'
import { solanaService } from '@/lib/solana/solana.service'
import { SolanaServiceError } from '@/lib/types'
import { ErrorCodes, apiError } from '@/lib/errors'

export async function GET(request: NextRequest) {
const address = request.nextUrl.searchParams.get('address')

if (!address || !solanaService.validateAddress(address)) {
return NextResponse.json(
apiError(ErrorCodes.ERR_INVALID_ADDRESS, 'address is required and must be a valid Solana address'),
{ status: 422 },
)
}

try {
const balances = await solanaService.getBalances(address)
const network = solanaService.getNetwork()
return NextResponse.json(
{
network: network.name,
address,
balances,
},
{ status: 200 },
)
} catch (err) {
if (err instanceof SolanaServiceError) {
return NextResponse.json({ error: err.message }, { status: 502 })
}
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch balances' },
{ status: 500 },
)
}
}
75 changes: 75 additions & 0 deletions app/api/solana/send/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* POST /api/solana/send
*
* Builds, signs, and broadcasts a real native SOL transfer on Solana devnet,
* mirroring app/api/evm/send/route.ts. MVP scope is native SOL transfers
* only — SPL-token (USDC) sends are follow-up work.
*/

import { NextRequest, NextResponse } from 'next/server'
import { validateBearerToken } from '@/lib/auth'
import { ErrorCodes, apiError } from '@/lib/errors'
import { solanaService } from '@/lib/solana/solana.service'
import { getSolanaPaymentService, SolanaPaymentConfigError } from '@/lib/solana/solana-payment.service'
import type { SolanaTransactionResult } from '@/lib/types'

interface SendBody {
destination?: string
amount?: number
}

export async function POST(request: NextRequest) {
if (!validateBearerToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

let body: SendBody = {}
try {
body = (await request.json()) as SendBody
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}

const { destination, amount } = body

if (!destination || typeof destination !== 'string' || !solanaService.validateAddress(destination)) {
return NextResponse.json(
apiError(ErrorCodes.ERR_INVALID_ADDRESS, 'destination is required and must be a valid Solana address'),
{ status: 422 },
)
}

if (!amount || typeof amount !== 'number' || amount <= 0) {
return NextResponse.json(
apiError(ErrorCodes.ERR_INVALID_AMOUNT, 'amount must be a positive number'),
{ status: 422 },
)
}

const paymentService = getSolanaPaymentService()

try {
const submission = await paymentService.submitPayment({ destination, amount })

const result: SolanaTransactionResult = {
success: submission.status !== 'failed',
signature: submission.signature,
status: submission.status,
error: submission.error,
}

return NextResponse.json(result, { status: 200 })
} catch (err) {
if (err instanceof SolanaPaymentConfigError) {
return NextResponse.json(
apiError(ErrorCodes.ERR_PAYMENT_NOT_CONFIGURED, err.message),
{ status: 503 },
)
}

return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Payment submission failed' },
{ status: 500 },
)
}
}
43 changes: 43 additions & 0 deletions app/api/sui/balance/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* GET /api/sui/balance?address=0x...
*
* Real, read-only balance query against Sui testnet RPC — no signing key
* required. See lib/sui/sui.service.ts.
*/

import { NextRequest, NextResponse } from 'next/server'
import { suiService } from '@/lib/sui/sui.service'
import { SuiServiceError } from '@/lib/types'
import { ErrorCodes, apiError } from '@/lib/errors'

export async function GET(request: NextRequest) {
const address = request.nextUrl.searchParams.get('address')

if (!address || !suiService.validateAddress(address)) {
return NextResponse.json(
apiError(ErrorCodes.ERR_INVALID_ADDRESS, 'address is required and must be a valid Sui address'),
{ status: 422 },
)
}

try {
const balances = await suiService.getBalances(address)
const network = suiService.getNetwork()
return NextResponse.json(
{
network: network.name,
address,
balances,
},
{ status: 200 },
)
} catch (err) {
if (err instanceof SuiServiceError) {
return NextResponse.json({ error: err.message }, { status: 502 })
}
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch balances' },
{ status: 500 },
)
}
}
75 changes: 75 additions & 0 deletions app/api/sui/send/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* POST /api/sui/send
*
* Builds, signs, and broadcasts a real native SUI transfer on Sui testnet,
* mirroring app/api/evm/send/route.ts. MVP scope is native SUI transfers
* only — coin-type (USDC) sends are follow-up work.
*/

import { NextRequest, NextResponse } from 'next/server'
import { validateBearerToken } from '@/lib/auth'
import { ErrorCodes, apiError } from '@/lib/errors'
import { suiService } from '@/lib/sui/sui.service'
import { getSuiPaymentService, SuiPaymentConfigError } from '@/lib/sui/sui-payment.service'
import type { SuiTransactionResult } from '@/lib/types'

interface SendBody {
destination?: string
amount?: number
}

export async function POST(request: NextRequest) {
if (!validateBearerToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

let body: SendBody = {}
try {
body = (await request.json()) as SendBody
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}

const { destination, amount } = body

if (!destination || typeof destination !== 'string' || !suiService.validateAddress(destination)) {
return NextResponse.json(
apiError(ErrorCodes.ERR_INVALID_ADDRESS, 'destination is required and must be a valid Sui address'),
{ status: 422 },
)
}

if (!amount || typeof amount !== 'number' || amount <= 0) {
return NextResponse.json(
apiError(ErrorCodes.ERR_INVALID_AMOUNT, 'amount must be a positive number'),
{ status: 422 },
)
}

const paymentService = getSuiPaymentService()

try {
const submission = await paymentService.submitPayment({ destination, amount })

const result: SuiTransactionResult = {
success: submission.status !== 'failed',
digest: submission.digest,
status: submission.status,
error: submission.error,
}

return NextResponse.json(result, { status: 200 })
} catch (err) {
if (err instanceof SuiPaymentConfigError) {
return NextResponse.json(
apiError(ErrorCodes.ERR_PAYMENT_NOT_CONFIGURED, err.message),
{ status: 503 },
)
}

return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Payment submission failed' },
{ status: 500 },
)
}
}
95 changes: 95 additions & 0 deletions app/api/wallet/send/prepare/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* POST /api/wallet/send/prepare — Issue #92
*
* Builds (but does not sign) a Stellar payment transaction for an arbitrary
* source account. Counterpart to /api/wallet/send/submit-signed. Together
* these two routes are the external-signer path (Ledger today): the private
* key never touches this server, so signing happens client-side against the
* XDR/signatureBase returned here.
*/

import { NextRequest, NextResponse } from 'next/server'
import { StrKey } from '@stellar/stellar-sdk'
import { validateBearerToken } from '@/lib/auth'
import { ErrorCodes, apiError } from '@/lib/errors'
import { SUPPORTED_STELLAR_ASSETS } from '@/lib/fixtures'
import { getStellarPaymentService, StellarPaymentConfigError } from '@/lib/services/stellar-payment.service'

interface PrepareBody {
sourcePublicKey?: string
destination?: string
amount?: number
asset?: string
memo?: string
}

const MAX_MEMO_BYTES = 28

export async function POST(request: NextRequest) {
if (!validateBearerToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

let body: PrepareBody = {}
try {
body = (await request.json()) as PrepareBody
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}

const { sourcePublicKey, destination, amount, asset, memo } = body

if (!sourcePublicKey || !StrKey.isValidEd25519PublicKey(sourcePublicKey)) {
return NextResponse.json(
apiError(ErrorCodes.ERR_INVALID_ADDRESS, 'sourcePublicKey is required and must be a valid Stellar public key'),
{ status: 422 },
)
}

if (!destination || !StrKey.isValidEd25519PublicKey(destination)) {
return NextResponse.json(
apiError(ErrorCodes.ERR_INVALID_ADDRESS, 'destination is required and must be a valid Stellar public key'),
{ status: 422 },
)
}

if (!amount || typeof amount !== 'number' || amount <= 0) {
return NextResponse.json(
apiError(ErrorCodes.ERR_INVALID_AMOUNT, 'amount must be a positive number'),
{ status: 422 },
)
}

if (!asset || !SUPPORTED_STELLAR_ASSETS.some((a) => a.code === asset)) {
return NextResponse.json(
apiError(ErrorCodes.ERR_UNSUPPORTED_ASSET, `asset must be one of: ${SUPPORTED_STELLAR_ASSETS.map((a) => a.code).join(', ')}`),
{ status: 422 },
)
}

if (memo && Buffer.byteLength(memo, 'utf8') > MAX_MEMO_BYTES) {
return NextResponse.json(
apiError(ErrorCodes.ERR_MEMO_TOO_LONG, `memo must be at most ${MAX_MEMO_BYTES} bytes`),
{ status: 422 },
)
}

try {
const prepared = await getStellarPaymentService().buildUnsignedTransaction({
sourcePublicKey,
destination,
amount,
asset,
memo,
})
return NextResponse.json(prepared, { status: 200 })
} catch (err) {
if (err instanceof StellarPaymentConfigError) {
return NextResponse.json(apiError(ErrorCodes.ERR_PAYMENT_NOT_CONFIGURED, err.message), { status: 503 })
}
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to build transaction' },
{ status: 500 },
)
}
}
Loading
Loading