Skip to content
Open
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 client/src/lib/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,24 @@ export class StellarEscrowClient {
// Similar pattern for get_trade_detail
// Implement based on backend TradeDetail
}

/**
* Call claim_time_release on the contract once the trade's expiry has passed.
* Anyone can call this — the contract enforces the time-lock check.
*/
async executeAutoRelease(tradeId: number, callerKeypair: string): Promise<string> {
const account = await this.server.getAccount(callerKeypair);
const tx = new TransactionBuilder(account, { fee: BASE_FEE })
.addInvocation(
this.contract.call('claim_time_release', xdr.ScVal.scvU64(tradeId))
)
.setTimeout(30)
.setNetworkPassphrase('Test SDF Network ; September 2015')
.build();

const txHash = await this.rpcServer.sendTransaction(tx);
return txHash;
}
}

export const escrowClient = new StellarEscrowClient('testnet', 'YOUR_CONTRACT_ID');
105 changes: 102 additions & 3 deletions client/src/routes/trades/[id]/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { FundingPreview, escrowClient } from '$lib/contract';

let tradeId = $page.params.id;
Expand All @@ -13,8 +13,61 @@
let success = false;
let error = '';

let isBuyer = false; // Simulate - from wallet connect
let tradeStatus = 'Created'; // Simulate from get_trade_detail
let isBuyer = false;
let tradeStatus = 'Funded'; // Simulate from get_trade_detail

// --- Expiry / auto-release (Issue #123) ---
// expiryTimestamp: Unix seconds, null if no expiry set
let expiryTimestamp: number | null = Math.floor(Date.now() / 1000) + 120; // mock: 2 min from now
let countdown = '';
let isExpired = false;
let releasing = false;
let releaseError = '';
let releaseTxHash = '';
let countdownInterval: ReturnType<typeof setInterval> | null = null;

function pad(n: number) { return String(n).padStart(2, '0'); }

function tickCountdown() {
if (!expiryTimestamp) return;
const remaining = expiryTimestamp - Math.floor(Date.now() / 1000);
if (remaining <= 0) {
countdown = 'Expired';
isExpired = true;
if (countdownInterval) clearInterval(countdownInterval);
} else {
const h = Math.floor(remaining / 3600);
const m = Math.floor((remaining % 3600) / 60);
const s = remaining % 60;
countdown = `${pad(h)}:${pad(m)}:${pad(s)}`;
}
}

async function handleAutoRelease() {
releasing = true;
releaseError = '';
try {
// releaseTxHash = await escrowClient.executeAutoRelease(parseInt(tradeId), 'callerKeypair');
releaseTxHash = 'mock_release_tx_456';
tradeStatus = 'Released';
isExpired = false; // hide button after release
} catch (e: any) {
releaseError = e?.message ?? 'Release failed';
}
releasing = false;
}

onMount(async () => {
await loadPreview();
if (expiryTimestamp) {
tickCountdown();
countdownInterval = setInterval(tickCountdown, 1000);
}
});

onDestroy(() => {
if (countdownInterval) clearInterval(countdownInterval);
});

async function loadPreview() {
try {
Expand Down Expand Up @@ -142,6 +195,52 @@
{/if}
</div>
</div>

<!-- Expiry / Auto-Release (Issue #123) -->
{#if expiryTimestamp}
<div class="bg-white rounded-2xl shadow-xl p-8 mt-8">
<h2 class="text-2xl font-bold mb-4">Time-Lock Expiry</h2>

<div class="flex items-center gap-4 mb-6">
<div class="text-center">
<p class="text-sm text-gray-500 mb-1">Auto-release in</p>
<p
class="text-3xl font-mono font-bold {isExpired ? 'text-red-600' : 'text-gray-800'}"
aria-live="polite"
aria-label="Countdown to auto-release"
>
{countdown || '—'}
</p>
</div>
</div>

{#if isExpired && tradeStatus === 'Funded' && !releaseTxHash}
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4 mb-4">
<p class="text-yellow-800 text-sm font-medium">
The time-lock has expired. You can now release the escrowed funds.
</p>
</div>
<button
on:click={handleAutoRelease}
disabled={releasing}
class="bg-gradient-to-r from-orange-500 to-red-600 text-white px-8 py-3 rounded-xl font-semibold hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Release escrowed funds for trade {tradeId}"
>
{releasing ? 'Releasing…' : 'Release Funds'}
</button>
{#if releaseError}
<p class="text-red-600 text-sm mt-3">{releaseError}</p>
{/if}
{/if}

{#if releaseTxHash}
<div class="bg-green-50 border border-green-200 rounded-xl p-4">
<p class="text-green-800 font-semibold">Funds released successfully!</p>
<p class="text-sm text-green-700 mt-1">Tx: {releaseTxHash}</p>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
Expand Down
153 changes: 153 additions & 0 deletions frontend/tradeExpiry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Trade Expiry Auto-Release UI (Issue #123)
*
* Renders a countdown timer on the trade detail page and shows a
* "Release Funds" button only once the expiry timestamp has passed.
* Calls the contract's `claim_time_release` function on confirmation.
*/

(function () {
'use strict';

// -------------------------------------------------------------------------
// Countdown timer
// -------------------------------------------------------------------------

/**
* Start a live countdown inside `container` targeting `expiryTimestamp` (Unix seconds).
* Calls `onExpired()` when the countdown reaches zero.
*
* @param {HTMLElement} container
* @param {number} expiryTimestamp - Unix epoch seconds
* @param {Function} onExpired
* @returns {number} intervalId — call clearInterval() to stop
*/
function startCountdown(container, expiryTimestamp, onExpired) {
function tick() {
const remaining = expiryTimestamp - Math.floor(Date.now() / 1000);

if (remaining <= 0) {
container.textContent = 'Expired';
container.setAttribute('aria-label', 'Trade has expired');
clearInterval(id);
onExpired();
return;
}

const h = Math.floor(remaining / 3600);
const m = Math.floor((remaining % 3600) / 60);
const s = remaining % 60;
const label = `${pad(h)}:${pad(m)}:${pad(s)}`;
container.textContent = label;
container.setAttribute('aria-label', `Time until auto-release: ${label}`);
}

tick();
const id = setInterval(tick, 1000);
return id;
}

function pad(n) {
return String(n).padStart(2, '0');
}

// -------------------------------------------------------------------------
// Release button
// -------------------------------------------------------------------------

/**
* Show the "Release Funds" button and wire up the contract call.
*
* @param {HTMLElement} buttonContainer
* @param {number|string} tradeId
* @param {Function} executeAutoRelease - async (tradeId) => { txHash }
*/
function showReleaseButton(buttonContainer, tradeId, executeAutoRelease) {
buttonContainer.innerHTML = `
<button
id="release-funds-btn"
class="btn btn-primary btn-release-funds"
type="button"
aria-label="Release funds for trade ${tradeId}"
>
Release Funds
</button>
<p id="release-status" class="release-status" aria-live="polite"></p>
`;

const btn = buttonContainer.querySelector('#release-funds-btn');
const status = buttonContainer.querySelector('#release-status');

btn.addEventListener('click', async () => {
if (!confirm('Release the escrowed funds now? This cannot be undone.')) return;

btn.disabled = true;
btn.textContent = 'Releasing…';
status.textContent = '';

try {
const txHash = await executeAutoRelease(tradeId);
btn.textContent = 'Released ✓';
status.textContent = `Transaction: ${txHash}`;
status.className = 'release-status success';
document.dispatchEvent(new CustomEvent('trade:auto_released', {
detail: { tradeId, txHash },
bubbles: true,
}));
} catch (err) {
btn.disabled = false;
btn.textContent = 'Release Funds';
status.textContent = `Error: ${err.message}`;
status.className = 'release-status error';
}
});
}

// -------------------------------------------------------------------------
// Main init
// -------------------------------------------------------------------------

/**
* Initialise the expiry UI for a trade detail page.
*
* @param {object} options
* @param {number|string} options.tradeId
* @param {number|null} options.expiryTimestamp - Unix seconds, or null if no expiry
* @param {string} options.tradeStatus - e.g. "Funded", "Created"
* @param {HTMLElement} options.countdownEl - element to render the countdown in
* @param {HTMLElement} options.releaseContainerEl - element to render the release button in
* @param {Function} options.executeAutoRelease - async (tradeId) => txHash
*/
function init({ tradeId, expiryTimestamp, tradeStatus, countdownEl, releaseContainerEl, executeAutoRelease }) {
if (!expiryTimestamp) {
if (countdownEl) countdownEl.textContent = 'No expiry set';
return;
}

const alreadyExpired = expiryTimestamp <= Math.floor(Date.now() / 1000);
const isFunded = tradeStatus === 'Funded';

if (alreadyExpired && isFunded) {
if (countdownEl) countdownEl.textContent = 'Expired';
if (releaseContainerEl) showReleaseButton(releaseContainerEl, tradeId, executeAutoRelease);
return;
}

if (countdownEl) {
startCountdown(countdownEl, expiryTimestamp, () => {
// Only show release button if trade is still in Funded state
if (isFunded && releaseContainerEl) {
showReleaseButton(releaseContainerEl, tradeId, executeAutoRelease);
}
});
}
}

// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------

window.TradeExpiry = { init, startCountdown, showReleaseButton };

console.log('Trade Expiry module loaded');
})();