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
18 changes: 18 additions & 0 deletions hooks/useWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getUserInfo,
getNetworkDetails,
isAllowed,
signTransaction as freighterSignTransaction,
} from "@stellar/freighter-api";

export interface AccountInfo {
Expand Down Expand Up @@ -32,6 +33,8 @@ export interface WalletState {
connect: () => Promise<void>;
/** Clear the local connection state so the UI prompts to connect again */
disconnect: () => void;
/** Requests a Freighter signature for a transaction XDR envelope, returning the signed XDR */
signTransaction: (xdr: string) => Promise<string>;
}

/**
Expand Down Expand Up @@ -151,6 +154,20 @@ export function useWallet(): WalletState {
setError(null);
}, []);

const signTransaction = useCallback(
async (xdr: string): Promise<string> => {
if (!account) {
throw new Error("Connect a wallet before signing a transaction");
}

return freighterSignTransaction(xdr, {
networkPassphrase: network?.networkPassphrase,
accountToSign: account.address,
});
},
[account, network]
);

return {
account,
network,
Expand All @@ -159,5 +176,6 @@ export function useWallet(): WalletState {
error,
connect,
disconnect,
signTransaction,
};
}
81 changes: 74 additions & 7 deletions pages/create-gig.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useState } from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { Navbar } from '../components/organisms'
import { useGlobalToast } from './_app'
import { useWallet } from '../hooks'
import { createGigEscrow, isValidStellarAddress } from '../shared/escrow-contract'

interface Milestone {
id: string
Expand All @@ -17,6 +20,8 @@ interface FormData {
description: string
category: string
totalBudget: string
/** The freelancer's Stellar address; the escrow contract requires a beneficiary up front. */
beneficiaryAddress: string
milestones: Milestone[]
}

Expand All @@ -31,10 +36,14 @@ const CreateGig: NextPage = () => {
description: '',
category: 'Development',
totalBudget: '',
beneficiaryAddress: '',
milestones: [{ id: '1', title: '', description: '', amount: '', duration: '' }],
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const toast = useGlobalToast()
const router = useRouter()
const { account, signTransaction } = useWallet()

const addMilestone = () => {
setFormData({
Expand Down Expand Up @@ -73,6 +82,11 @@ const CreateGig: NextPage = () => {
if (!formData.totalBudget || Number(formData.totalBudget) <= 0) {
newErrors.totalBudget = 'Valid budget is required'
}
if (!formData.beneficiaryAddress.trim()) {
newErrors.beneficiaryAddress = "Freelancer's wallet address is required"
} else if (!isValidStellarAddress(formData.beneficiaryAddress.trim())) {
newErrors.beneficiaryAddress = 'Enter a valid Stellar address (starts with G)'
}
}

if (currentStep === 1) {
Expand All @@ -98,11 +112,35 @@ const CreateGig: NextPage = () => {
setCurrentStep((prev) => Math.max(prev - 1, 0))
}

const handleSubmit = () => {
if (validateStep()) {
toast.success('Gig created successfully!')
console.log('Gig data:', formData)
// Here you would integrate with smart contract
const handleSubmit = async () => {
if (!validateStep()) return

if (!account) {
toast.error('Connect your Stellar wallet before creating a gig')
return
}

setIsSubmitting(true)
try {
const { escrowId } = await createGigEscrow(
{
depositor: account.address,
beneficiary: formData.beneficiaryAddress.trim(),
milestones: formData.milestones.map((m) => ({
label: m.title,
amount: m.amount,
})),
},
signTransaction
)

toast.success(`Gig created and escrow #${escrowId} funded successfully!`)
router.push('/dashboard')
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create gig escrow'
toast.error(message)
} finally {
setIsSubmitting(false)
}
}

Expand Down Expand Up @@ -244,6 +282,28 @@ const CreateGig: NextPage = () => {
{errors.totalBudget && <p className="text-red-500 text-sm mt-1">{errors.totalBudget}</p>}
</div>
</div>

<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Freelancer&apos;s Wallet Address *
</label>
<input
type="text"
value={formData.beneficiaryAddress}
onChange={(e) => setFormData({ ...formData, beneficiaryAddress: e.target.value })}
placeholder="G..."
className={`w-full px-4 py-3 bg-white dark:bg-gray-800 border rounded-lg text-gray-900 dark:text-white font-mono text-sm ${
errors.beneficiaryAddress ? 'border-red-500' : 'border-gray-300 dark:border-gray-700'
}`}
/>
<p className="text-gray-500 dark:text-gray-500 text-xs mt-1">
The escrow contract locks funds for a specific freelancer up front. Enter the Stellar
address of the freelancer you&apos;re hiring for this gig.
</p>
{errors.beneficiaryAddress && (
<p className="text-red-500 text-sm mt-1">{errors.beneficiaryAddress}</p>
)}
</div>
</div>
)}

Expand Down Expand Up @@ -360,6 +420,12 @@ const CreateGig: NextPage = () => {
<span className="text-gray-600 dark:text-gray-400">Milestones:</span>
<span className="font-medium text-gray-900 dark:text-white">{formData.milestones.length}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-gray-600 dark:text-gray-400 shrink-0">Freelancer:</span>
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs break-all text-right">
{formData.beneficiaryAddress}
</span>
</div>
</div>
</div>

Expand Down Expand Up @@ -422,9 +488,10 @@ const CreateGig: NextPage = () => {
) : (
<button
onClick={handleSubmit}
className="px-6 py-2.5 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors"
disabled={isSubmitting}
className="px-6 py-2.5 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Create Gig
{isSubmitting ? 'Creating Escrow…' : 'Create Gig'}
</button>
)}
</div>
Expand Down
173 changes: 173 additions & 0 deletions shared/escrow-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { Address, BASE_FEE, Contract, StrKey, TransactionBuilder, nativeToScVal, scValToNative, rpc } from '@stellar/stellar-sdk'
import { getSorobanServer } from './soroban-rpc'
import { ESCROW_CONTRACT_ID, NETWORK_PASSPHRASE } from './contracts'

const STROOPS_PER_XLM = 10_000_000

/**
* Maps the `TrustFlowError` enum from the on-chain contract
* (trustflow-protocol/trustflow-contract, contracts/trustflow/src/lib.rs)
* to a human-readable message. Soroban surfaces contract errors as strings
* like "... Error(Contract, #3) ..." in RPC/simulation failures.
*/
const CONTRACT_ERROR_MESSAGES: Record<number, string> = {
1: 'Unauthorized: the connected wallet is not allowed to perform this action',
2: 'Escrow not found',
3: 'Invalid amount: every milestone amount must be greater than zero',
4: 'Dispute not found',
5: 'This dispute has already been resolved',
6: 'The escrow is not in a valid state for this action',
7: 'This juror has already voted on this dispute',
8: 'Insufficient staked balance',
9: 'No votes have been cast on this dispute',
10: 'Milestone amounts do not match the escrow total',
}

function describeContractError(message: string): string {
const match = message.match(/Error\(Contract,\s*#(\d+)\)/)
if (match) {
const code = Number(match[1])
return CONTRACT_ERROR_MESSAGES[code] ?? message
}
return message
}

function xlmToStroops(amount: string): bigint {
return BigInt(Math.round(Number(amount) * STROOPS_PER_XLM))
}

export function isValidStellarAddress(address: string): boolean {
return StrKey.isValidEd25519PublicKey(address)
}

export interface EscrowMilestoneInput {
label: string
amount: string
}

export interface CreateGigEscrowInput {
/** The gig poster's wallet address; funds are drawn from this account. */
depositor: string
/** The freelancer's wallet address; receives the escrowed funds on release/settlement. */
beneficiary: string
milestones: EscrowMilestoneInput[]
}

export interface CreateGigEscrowResult {
escrowId: string
txHash: string
}

/**
* Signs a transaction XDR envelope, returning the signed XDR. Matches the
* shape of Freighter's `signTransaction`, but kept generic so this module
* doesn't depend on a specific wallet.
*/
export type SignTransaction = (xdr: string) => Promise<string>

function milestoneToScVal(milestone: EscrowMilestoneInput) {
return nativeToScVal(
{
label: milestone.label,
amount: xlmToStroops(milestone.amount),
approved: false,
},
{
type: {
label: ['symbol', 'string'],
amount: ['symbol', 'i128'],
approved: ['symbol', null],
},
}
)
}

/**
* Builds an `init_escrow` invocation on the TrustFlow contract, signs it via
* the caller-supplied `signTransaction`, submits it to Soroban RPC, and
* polls until it lands on-chain. Returns the new escrow ID and tx hash.
*
* There's no generated TypeScript client for the contract yet, so the
* invocation is built by hand against the ABI in
* trustflow-protocol/trustflow-contract (contracts/trustflow/src/lib.rs):
*
* fn init_escrow(depositor: Address, beneficiary: Address, milestones: Vec<Milestone>) -> Result<u64, TrustFlowError>
* struct Milestone { label: String, amount: i128, approved: bool }
*
* The contract locks `sum(milestones[].amount)` of its configured token from
* `depositor` and requires `beneficiary` up front (there's no on-chain
* method to change it later), so this must be called with the chosen
* freelancer's address already known.
*/
export async function createGigEscrow(
input: CreateGigEscrowInput,
signTransaction: SignTransaction
): Promise<CreateGigEscrowResult> {
if (!ESCROW_CONTRACT_ID) {
throw new Error('Escrow contract is not configured (set NEXT_PUBLIC_ESCROW_CONTRACT_ID)')
}
if (input.milestones.length === 0) {
throw new Error('At least one milestone is required')
}

const server = getSorobanServer()
const sourceAccount = await server.getAccount(input.depositor)
const contract = new Contract(ESCROW_CONTRACT_ID)

const operation = contract.call(
'init_escrow',
Address.fromString(input.depositor).toScVal(),
Address.fromString(input.beneficiary).toScVal(),
nativeToScVal(input.milestones.map(milestoneToScVal))
)

const transaction = new TransactionBuilder(sourceAccount, {
fee: BASE_FEE,
networkPassphrase: NETWORK_PASSPHRASE,
})
.addOperation(operation)
.setTimeout(30)
.build()

let preparedTransaction
try {
preparedTransaction = await server.prepareTransaction(transaction)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(describeContractError(message))
}

const signedXdr = await signTransaction(preparedTransaction.toXDR())
const signedTransaction = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE)

const sendResult = await server.sendTransaction(signedTransaction)
if (sendResult.status === 'ERROR' || sendResult.status === 'DUPLICATE') {
throw new Error(describeContractError(`Failed to submit transaction (status: ${sendResult.status})`))
}

return waitForTransaction(server, sendResult.hash)
}

async function waitForTransaction(
server: rpc.Server,
hash: string,
attempts = 15,
intervalMs = 1500
): Promise<CreateGigEscrowResult> {
for (let attempt = 0; attempt < attempts; attempt++) {
const result = await server.getTransaction(hash)

if (result.status === rpc.Api.GetTransactionStatus.SUCCESS) {
const escrowId = result.returnValue ? String(scValToNative(result.returnValue)) : ''
return { escrowId, txHash: hash }
}

if (result.status === rpc.Api.GetTransactionStatus.FAILED) {
throw new Error('Transaction failed on-chain')
}

await new Promise((resolve) => setTimeout(resolve, intervalMs))
}

throw new Error('Timed out waiting for transaction confirmation')
}
Loading