From 1ea566428a6dc40d1ee378723052ba5108b0f7a7 Mon Sep 17 00:00:00 2001 From: levai Date: Fri, 30 Oct 2026 13:15:56 +0100 Subject: [PATCH 1/2] feat: add the Target Savings and Spend & Save flows --- index.html | 171 ++++++++++++++++++++++++ src/main.ts | 42 +++++- src/onboarding.ts | 14 +- src/session.ts | 47 +++++++ src/spend-save.ts | 303 ++++++++++++++++++++++++++++++++++++++++++ src/style.css | 54 +++++++- src/target-savings.ts | 272 +++++++++++++++++++++++++++++++++++++ src/ui.ts | 79 ++++++++++- 8 files changed, 972 insertions(+), 10 deletions(-) create mode 100644 src/session.ts create mode 100644 src/spend-save.ts create mode 100644 src/target-savings.ts diff --git a/index.html b/index.html index c7f9cdc..1626cd6 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,12 @@

Loktin SDK example

Testnet only. Test assets have no value.

+
+

This session

+

No transactions yet.

+ +
+

Account

Connect a wallet to see your balances.

@@ -34,6 +40,171 @@

Get started

+ + + +

Network

diff --git a/src/main.ts b/src/main.ts index b0d6faa..cea02e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,11 +3,42 @@ import { EXPLORER_URL, TESTNET } from './config'; import { createApp, type LoktinApp } from './loktin'; import { renderOnboarding } from './onboarding'; import { element, link, showError } from './ui'; +import { setupSpendSave } from './spend-save'; +import { setupTargetSavings } from './target-savings'; import { initWallet } from './wallet'; let app: LoktinApp | undefined; let address: string | undefined; let latestRefresh = 0; +let savingsFor: string | undefined; + +function getApp(): LoktinApp { + if (!app) throw new Error('The SDK did not start.'); + return app; +} + +const spendSave = setupSpendSave({ + getApp, + refreshBalances: () => void refresh(), + refreshGoals: () => targetSavings.load(), +}); +const targetSavings = setupTargetSavings({ + getApp, + refreshBalances: () => void refresh(), + onGoals: (goals) => spendSave.setGoals(goals), +}); + +/** Shows and loads the savings sections once per connected address. */ +function showSavings(next: string | undefined): void { + element('target-savings').hidden = !next; + element('spend-save').hidden = !next; + if (next === savingsFor) return; + savingsFor = next; + if (next) { + void targetSavings.load(); + void spendSave.load(); + } +} function renderBalances(balances: Balances | undefined): void { const message = element('account-message'); @@ -46,7 +77,11 @@ async function refresh(): Promise { showError(error, undefined); try { const balances = current ? await fetchBalances(current) : undefined; - if (request === latestRefresh) render(balances); + if (request === latestRefresh) { + render(balances); + const ready = balances?.exists && balances.usdc !== undefined; + showSavings(ready ? current : undefined); + } } catch (cause) { if (request === latestRefresh) { render(undefined); @@ -61,6 +96,8 @@ function onAddress(next: string | undefined): void { element('account-address').textContent = next ?? 'Connect a wallet to see your balances.'; element('refresh').hidden = !next; + showSavings(undefined); + app = undefined; try { app = createApp(next); } catch (cause) { @@ -81,8 +118,7 @@ async function renderNetwork(): Promise { const minCadence = element('network-min-cadence'); try { - if (!app) throw new Error('The SDK did not start.'); - const seconds = await app.targetSavings.minCadence(); + const seconds = await getApp().targetSavings.minCadence(); minCadence.textContent = `${seconds} seconds`; } catch (cause) { minCadence.textContent = 'Not available'; diff --git a/src/onboarding.ts b/src/onboarding.ts index 047859b..4bdb3b8 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -1,3 +1,4 @@ +import { PendingError, TransactionFailedError } from '@loktin/sdk'; import { BASE_FEE, Operation, @@ -6,13 +7,14 @@ import { import { Api, Server } from '@stellar/stellar-sdk/rpc'; import type { Balances } from './balances'; import { FRIENDBOT_URL, TESTNET, USDC, USDC_FAUCET_URL } from './config'; +import { recordedWrite, type Write } from './session'; import { link, showError } from './ui'; import { signer } from './wallet'; const POLL_ATTEMPTS = 30; const POLL_INTERVAL_MS = 1000; -async function addTrustline(address: string): Promise { +async function addTrustline(address: string): Promise { const server = new Server(TESTNET.rpcUrl); const account = await server.getAccount(address); const transaction = new TransactionBuilder(account, { @@ -35,13 +37,15 @@ async function addTrustline(address: string): Promise { for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) { const result = await server.getTransaction(sent.hash); - if (result.status === Api.GetTransactionStatus.SUCCESS) return; + if (result.status === Api.GetTransactionStatus.SUCCESS) { + return { hash: sent.hash, ledger: result.ledger }; + } if (result.status === Api.GetTransactionStatus.FAILED) { - throw new Error(`The trustline transaction failed: ${sent.hash}.`); + throw new TransactionFailedError(sent.hash, undefined, undefined); } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } - throw new Error(`The trustline is still pending: ${sent.hash}.`); + throw new PendingError(sent.hash, undefined); } function friendbotStep(address: string): HTMLElement[] { @@ -67,7 +71,7 @@ function trustlineStep(address: string, onDone: () => void): HTMLElement[] { button.addEventListener('click', () => { button.disabled = true; showError(error, undefined); - addTrustline(address) + recordedWrite('Add the USDC trustline', () => addTrustline(address)) .then(onDone) .catch((cause: unknown) => showError(error, cause)) .finally(() => { diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..ea2a00f --- /dev/null +++ b/src/session.ts @@ -0,0 +1,47 @@ +import { PendingError, TransactionFailedError } from '@loktin/sdk'; +import { EXPLORER_URL } from './config'; +import { element, link } from './ui'; + +export interface Write { + readonly hash: string; + readonly ledger: number; +} + +function hashLink(hash: string): HTMLAnchorElement { + return link(hash, `${EXPLORER_URL}/tx/${hash}`); +} + +function addEntry(label: string, hash: string, outcome: string): void { + const entry = document.createElement('li'); + entry.append(`${label}: `, hashLink(hash), `, ${outcome}`); + element('session-list').append(entry); + element('session-list').hidden = false; + element('session-empty').hidden = true; +} + +/** Runs a write and adds it to the session list, also when it fails or stays pending. */ +export async function recordedWrite( + label: string, + write: () => Promise, +): Promise { + try { + const result = await write(); + addEntry(label, result.hash, `ledger ${result.ledger}`); + return result; + } catch (error) { + if ( + error instanceof PendingError || + error instanceof TransactionFailedError + ) { + addEntry(label, error.hash, error.code); + } + throw error; + } +} + +/** The hash, linked to the explorer, and the ledger of a write. */ +export function writeLine({ hash, ledger }: Write): HTMLElement { + const line = document.createElement('p'); + line.append('Transaction ', hashLink(hash), `, ledger ${ledger}`); + return line; +} diff --git a/src/spend-save.ts b/src/spend-save.ts new file mode 100644 index 0000000..88bdac3 --- /dev/null +++ b/src/spend-save.ts @@ -0,0 +1,303 @@ +import { + ContractError, + fromBaseUnits, + toBaseUnits, + windowInTimeZone, +} from '@loktin/sdk'; +import type { EnrollInput, EnrollmentView } from '@loktin/sdk/spend-save'; +import type { Goal } from '@loktin/sdk/target-savings'; +import type { LoktinApp } from './loktin'; +import { recordedWrite, writeLine } from './session'; +import { + details, + element, + field, + onClick, + onSubmit, + paragraph, + showError, + usdc, +} from './ui'; + +interface Options { + readonly getApp: () => LoktinApp; + readonly refreshBalances: () => void; + readonly refreshGoals: () => Promise; +} + +function optionalUnits(id: string): bigint | undefined { + const { value } = field(id); + return value ? toBaseUnits(value) : undefined; +} + +function skimText({ skim }: EnrollmentView): string { + const parts = []; + if (skim.percentBps !== undefined) { + parts.push(`${skim.percentBps} basis points`); + } + if (skim.roundUpUnit !== undefined) { + parts.push(`round up to ${usdc(skim.roundUpUnit)}`); + } + return parts.join(', '); +} + +/** Clears a preview and locks its submit button when the amount changes. */ +function resetOnInput(form: string, preview: string, submit: string): void { + element(form).addEventListener('input', () => { + element(preview).replaceChildren(); + (element(submit) as HTMLButtonElement).disabled = true; + }); +} + +export function setupSpendSave({ + getApp, + refreshBalances, + refreshGoals, +}: Options) { + let enrolled = false; + let latestLoad = 0; + let goals: readonly Goal[] = []; + let destination = 'balance'; + + function renderDestinations(): void { + const select = element('enrol-destination') as HTMLSelectElement; + const ids = goals.map(({ id }) => String(id)); + if (destination !== 'balance' && !ids.includes(destination)) { + ids.push(destination); + } + const options = ids.map((id) => { + const open = goals.some((goal) => String(goal.id) === id); + return new Option(`Goal ${id}${open ? '' : ' (not open)'}`, id); + }); + select.replaceChildren( + new Option('Spend & Save balance', 'balance'), + ...options, + ); + select.value = destination; + } + + function renderStatus(enrollment: EnrollmentView | undefined): void { + enrolled = enrollment !== undefined; + const pending = enrollment?.pendingDay; + element('spend-status').replaceChildren( + enrollment + ? details([ + ['Balance', usdc(enrollment.principal)], + ['Skim', skimText(enrollment)], + [ + 'Monthly cap', + enrollment.monthlyCap === undefined + ? 'None' + : usdc(enrollment.monthlyCap), + ], + [ + 'Destination', + enrollment.destination.kind === 'goal' + ? `Goal ${enrollment.destination.goalId}` + : 'Spend & Save balance', + ], + ['Withdrawal day', String(enrollment.effectiveWithdrawalDay)], + ...(pending + ? ([['New withdrawal day', String(pending.day)]] as const) + : []), + ['Saved this month', usdc(enrollment.savedThisMonth)], + ['Total saved', usdc(enrollment.totalSaved)], + ]) + : paragraph('You are not enrolled in Spend & Save.'), + ); + element('spend-enrolled').hidden = !enrollment; + element('spend-close').hidden = enrollment?.principal !== 0n; + element('enrol-day-hint').hidden = !enrollment; + const title = enrollment ? 'Change settings' : 'Enrol'; + element('enrol-heading').textContent = title; + element('enrol-submit').textContent = enrollment ? 'Save settings' : title; + } + + function fillForm(enrollment: EnrollmentView): void { + const { skim, monthlyCap, pendingDay, withdrawalDay } = enrollment; + field('enrol-percent').value = skim.percentBps?.toString() ?? ''; + field('enrol-round-up').value = + skim.roundUpUnit === undefined ? '' : fromBaseUnits(skim.roundUpUnit); + field('enrol-cap').value = + monthlyCap === undefined ? '' : fromBaseUnits(monthlyCap); + field('enrol-day').value = String(pendingDay?.day ?? withdrawalDay); + destination = + enrollment.destination.kind === 'goal' + ? String(enrollment.destination.goalId) + : 'balance'; + renderDestinations(); + } + + async function renderWindow(): Promise { + const next = await getApp().spendSave.nextWindow(); + const local = windowInTimeZone(next); + const now = Date.now(); + const open = now >= next.start.getTime() && now < next.end.getTime(); + element('spend-window').replaceChildren( + paragraph(open ? 'The window is open now.' : 'The window is closed.'), + details([ + ['Opens (UTC)', next.start.toUTCString()], + ['Closes (UTC)', next.end.toUTCString()], + [`Opens (${local.timeZone})`, local.start], + [`Closes (${local.timeZone})`, local.end], + ]), + ); + } + + async function readEnrollment(): Promise { + try { + return await getApp().spendSave.enrollment(); + } catch (cause) { + if ( + cause instanceof ContractError && + cause.errorName === 'EnrollmentNotFound' + ) { + return undefined; + } + throw cause; + } + } + + /** Reads the enrolment and renders the status. Failures show in the section. */ + async function load(): Promise { + const request = ++latestLoad; + const error = element('spend-error'); + showError(error, undefined); + try { + const enrollment = await readEnrollment(); + if (request !== latestLoad) return; + renderStatus(enrollment); + if (enrollment) { + fillForm(enrollment); + await renderWindow(); + } + } catch (cause) { + if (request === latestLoad) showError(error, cause); + } + } + + async function afterWrite(): Promise { + await load(); + refreshBalances(); + } + + function readSettings(): EnrollInput { + const percent = field('enrol-percent').value; + const selected = field('enrol-destination').value; + return { + skim: { + percentBps: percent ? Number(percent) : undefined, + roundUpUnit: optionalUnits('enrol-round-up'), + }, + monthlyCap: optionalUnits('enrol-cap'), + destination: + selected === 'balance' + ? { kind: 'balance' } + : { kind: 'goal', goalId: BigInt(selected) }, + withdrawalDay: Number(field('enrol-day').value), + }; + } + + element('spend-refresh').addEventListener('click', () => void load()); + resetOnInput('pay-form', 'pay-preview-result', 'pay-submit'); + resetOnInput('withdraw-form', 'withdraw-preview-result', 'withdraw-submit'); + + onSubmit(element('enrol-form'), element('enrol-error'), async () => { + const input = readSettings(); + const { spendSave } = getApp(); + const write = await recordedWrite( + enrolled ? 'Change Spend & Save settings' : 'Enrol in Spend & Save', + () => + enrolled ? spendSave.updateSettings(input) : spendSave.enroll(input), + ); + element('enrol-result').replaceChildren(writeLine(write)); + await afterWrite(); + }); + + onClick(element('pay-preview'), element('pay-error'), async () => { + const units = toBaseUnits(field('pay-amount').value); + const preview = await getApp().spendSave.previewSkim(units); + element('pay-preview-result').replaceChildren( + details([ + ['Spend', usdc(preview.spent)], + ['Percentage part', usdc(preview.percentPart)], + ['Round-up part', usdc(preview.roundUpPart)], + ['Skim', usdc(preview.skim)], + ['Trimmed by the cap', usdc(preview.capTrimmed)], + ['Skim after the cap', usdc(preview.capped)], + ]), + ); + (element('pay-submit') as HTMLButtonElement).disabled = false; + }); + + onSubmit(element('pay-form'), element('pay-error'), async () => { + const units = toBaseUnits(field('pay-amount').value); + const { spendSave } = getApp(); + const write = await recordedWrite('Pay and save', () => + spendSave.payAndSave(field('pay-to').value, units), + ); + const saving = write.result; + element('pay-result').replaceChildren( + details([ + ['Skim', usdc(saving.skim)], + ['Trimmed by the cap', usdc(saving.capTrimmed)], + ['Not covered by the wallet', usdc(saving.shortfall)], + ['Saved to the balance', usdc(saving.toBalance)], + ['Saved to a goal', usdc(saving.toGoal)], + ...(saving.goalId === undefined + ? [] + : ([['Goal', String(saving.goalId)]] as const)), + ]), + writeLine(write), + ); + await refreshGoals(); + await afterWrite(); + }); + + onClick(element('withdraw-preview'), element('withdraw-error'), async () => { + const units = toBaseUnits(field('withdraw-amount').value); + const preview = await getApp().spendSave.previewWithdraw(units); + element('withdraw-preview-result').replaceChildren( + details([ + ['Amount', usdc(preview.amount)], + ['You receive', usdc(preview.toUser)], + ['Loktin fee', usdc(preview.loktinFee)], + ['Integrator fee', usdc(preview.integratorFee)], + ]), + ); + (element('withdraw-submit') as HTMLButtonElement).disabled = false; + }); + + onSubmit(element('withdraw-form'), element('withdraw-error'), async () => { + const units = toBaseUnits(field('withdraw-amount').value); + const message = `Withdraw ${usdc(units)}? Check the split above first.`; + if (!window.confirm(message)) return; + const { spendSave } = getApp(); + const write = await recordedWrite('Withdraw from Spend & Save', () => + spendSave.withdraw(units), + ); + element('withdraw-result').replaceChildren( + paragraph(`Paid ${usdc(write.result.toUser)}.`), + writeLine(write), + ); + await afterWrite(); + }); + + onClick(element('close-submit'), element('close-error'), async () => { + const { spendSave } = getApp(); + const write = await recordedWrite('Close Spend & Save', () => + spendSave.close(), + ); + element('enrol-result').replaceChildren(writeLine(write)); + await afterWrite(); + }); + + return { + load, + setGoals(next: readonly Goal[]): void { + goals = next; + destination = field('enrol-destination').value || destination; + renderDestinations(); + }, + }; +} diff --git a/src/style.css b/src/style.css index a0d26da..5a09dc3 100644 --- a/src/style.css +++ b/src/style.css @@ -109,10 +109,62 @@ button:disabled { opacity: 0.6; } -#account-address { +#account-address, +#session-list { overflow-wrap: anywhere; } +#session-list { + margin: 0; + padding-left: 24px; +} + +h3 { + margin: 16px 0 8px; + font-size: 1rem; +} + +label { + display: block; + margin-bottom: 8px; + font-size: 0.875rem; +} + +input, +select { + display: block; + width: 100%; + margin-top: 4px; + padding: 8px; + color: inherit; + font: inherit; + background: var(--background); + border: 1px solid var(--border); + border-radius: 6px; +} + +form { + margin-top: 16px; +} + +form button, +.goal button { + margin: 0 8px 8px 0; +} + +.goal { + margin-top: 12px; + padding: 12px; + background: var(--background); + border: 1px solid var(--border); + border-radius: 6px; +} + +.hint { + color: var(--muted); + font-size: 0.875rem; +} + .notice { color: var(--muted); } diff --git a/src/target-savings.ts b/src/target-savings.ts new file mode 100644 index 0000000..ac688cc --- /dev/null +++ b/src/target-savings.ts @@ -0,0 +1,272 @@ +import { toBaseUnits } from '@loktin/sdk'; +import { + creationBreakdown, + type Goal, + type WithdrawalRule, +} from '@loktin/sdk/target-savings'; +import { INTEGRATOR_FEE_BPS } from './config'; +import type { LoktinApp } from './loktin'; +import { recordedWrite, writeLine } from './session'; +import { + details, + element, + errorParagraph, + field, + onClick, + onSubmit, + paragraph, + showError, + usdc, +} from './ui'; + +const SECONDS_PER_DAY = 86400; + +interface Options { + readonly getApp: () => LoktinApp; + readonly refreshBalances: () => void; + readonly onGoals: (goals: readonly Goal[]) => void; +} + +function button(text: string): HTMLButtonElement { + const created = document.createElement('button'); + created.type = 'button'; + created.textContent = text; + return created; +} + +function ruleText(rule: WithdrawalRule): string { + return rule.kind === 'lock' ? 'Lock' : `Penalty, ${rule.bps} basis points`; +} + +function readRule(): WithdrawalRule { + return field('goal-rule').value === 'lock' + ? { kind: 'lock' } + : { kind: 'penalty', bps: Number(field('goal-penalty').value) }; +} + +export function setupTargetSavings({ + getApp, + refreshBalances, + onGoals, +}: Options) { + let loktinFeeBps: number | undefined; + let latestLoad = 0; + + function renderBreakdown(): void { + const output = element('goal-breakdown'); + const target = field('goal-target').value; + if (!target || loktinFeeBps === undefined) { + output.replaceChildren(); + return; + } + try { + const split = creationBreakdown({ + target: toBaseUnits(target), + rule: readRule(), + loktinFeeBps, + integratorFeeBps: INTEGRATOR_FEE_BPS, + }); + output.replaceChildren( + paragraph( + split.locked + ? 'A lock rule has no early withdrawal. Once the target is reached or the deadline passes, fees still apply:' + : 'An early withdrawal of the whole target would pay out:', + ), + details([ + ['You receive', usdc(split.toUser)], + ['Penalty', usdc(split.penalty)], + ['Loktin fee', usdc(split.loktinFee)], + ['Integrator fee', usdc(split.integratorFee)], + ]), + ); + } catch (cause) { + const error = errorParagraph(); + showError(error, cause); + output.replaceChildren(error); + } + } + + function renderRuleFields(): void { + const lock = field('goal-rule').value === 'lock'; + element('goal-penalty-field').hidden = lock; + field('goal-penalty').disabled = lock; + element('goal-lock-hint').hidden = !lock; + } + + async function afterWrite(): Promise { + await load(); + refreshBalances(); + } + + function goalCard(goal: Goal): HTMLElement { + const card = document.createElement('article'); + card.className = 'goal'; + const error = errorParagraph(); + const allowance = paragraph('Allowance: loading...'); + const amount = document.createElement('input'); + amount.inputMode = 'decimal'; + amount.ariaLabel = `Deposit amount for goal ${goal.id}, in USDC`; + amount.placeholder = 'Amount (USDC)'; + const deposit = button('Deposit'); + const renew = button('Renew allowance'); + const preview = button('Preview withdrawal'); + const split = document.createElement('div'); + const withdraw = button('Withdraw'); + withdraw.hidden = true; + + onClick(deposit, error, async () => { + const units = toBaseUnits(amount.value); + const { targetSavings } = getApp(); + const write = await recordedWrite(`Deposit into goal ${goal.id}`, () => + targetSavings.deposit(goal.id, units), + ); + element('goals-result').replaceChildren( + paragraph(`Deposited ${usdc(units)} into goal ${goal.id}.`), + writeLine(write), + ); + await afterWrite(); + }); + + onClick(renew, error, async () => { + const { targetSavings } = getApp(); + const write = await recordedWrite( + `Renew allowance for goal ${goal.id}`, + () => targetSavings.renewAllowance(goal.id), + ); + const { renewBy } = write.allowance; + element('goals-result').replaceChildren( + paragraph(`Allowance set to ${usdc(write.allowance.amount)}.`), + writeLine(write), + ...(renewBy ? [paragraph(`Renew by ${renewBy.toUTCString()}.`)] : []), + ); + await afterWrite(); + }); + + onClick(preview, error, async () => { + const result = await getApp().targetSavings.previewWithdraw(goal.id); + split.replaceChildren( + details([ + ['Amount', usdc(result.amount)], + ['You receive', usdc(result.toUser)], + ['Penalty', usdc(result.penalty)], + ['Loktin fee', usdc(result.loktinFee)], + ['Integrator fee', usdc(result.integratorFee)], + ]), + ); + withdraw.hidden = false; + }); + + onClick(withdraw, error, async () => { + const message = `Withdraw goal ${goal.id} and close it? Check the split above first.`; + if (!window.confirm(message)) return; + const { targetSavings } = getApp(); + const write = await recordedWrite(`Withdraw goal ${goal.id}`, () => + targetSavings.withdraw(goal.id), + ); + element('goals-result').replaceChildren( + paragraph(`Goal ${goal.id} paid ${usdc(write.result.toUser)}.`), + writeLine(write), + ); + await afterWrite(); + }); + + card.append( + details([ + ['Goal id', String(goal.id)], + ['Principal', usdc(goal.principal)], + ['Target', usdc(goal.target)], + ['Deadline', goal.deadline.toUTCString()], + ['Cadence', `${goal.cadenceSeconds / SECONDS_PER_DAY} days`], + ['Rule', ruleText(goal.rule)], + ]), + allowance, + amount, + deposit, + renew, + preview, + split, + withdraw, + error, + ); + + getApp() + .targetSavings.allowanceStatus(goal.id) + .then((status) => { + const short = status.short ? ' It is short: renew the allowance.' : ''; + allowance.textContent = `Allowance: ${usdc(status.allowance)} left, next collection ${usdc(status.nextAmount)}.${short}`; + }) + .catch((cause: unknown) => { + allowance.textContent = 'Allowance: not available.'; + showError(error, cause); + }); + return card; + } + + async function loadRates(): Promise { + const { targetSavings } = getApp(); + const [feeBps, minCadence] = await Promise.all([ + targetSavings.loktinFeeBps(), + targetSavings.minCadence(), + ]); + loktinFeeBps = feeBps; + const days = Math.ceil(minCadence / SECONDS_PER_DAY); + field('goal-cadence').min = String(days); + const hint = element('goal-cadence-hint'); + hint.textContent = `The shortest cadence is ${days} ${days === 1 ? 'day' : 'days'}.`; + hint.hidden = false; + renderBreakdown(); + } + + /** Reads the goals and renders the list. Failures show in the section. */ + async function load(): Promise { + const request = ++latestLoad; + const error = element('goals-error'); + showError(error, undefined); + try { + const app = getApp(); + if (loktinFeeBps === undefined) await loadRates(); + const goals = await app.targetSavings.userGoals(); + if (request !== latestLoad) return; + element('goals').replaceChildren( + goals.length === 0 + ? paragraph('You have no open goals.') + : paragraph(`${goals.length} of 20 open goals.`), + ...goals.map(goalCard), + ); + onGoals(goals); + } catch (cause) { + if (request === latestLoad) showError(error, cause); + } + } + + renderRuleFields(); + element('goal-rule').addEventListener('change', () => { + renderRuleFields(); + renderBreakdown(); + }); + element('goal-form').addEventListener('input', renderBreakdown); + element('goals-refresh').addEventListener('click', () => void load()); + + onSubmit(element('goal-form'), element('goal-error'), async () => { + const input = { + target: toBaseUnits(field('goal-target').value), + depositSize: toBaseUnits(field('goal-deposit').value), + cadenceSeconds: Number(field('goal-cadence').value) * SECONDS_PER_DAY, + deadline: new Date(`${field('goal-deadline').value}T00:00:00Z`), + rule: readRule(), + }; + const { targetSavings } = getApp(); + const goal = await recordedWrite('Create goal', () => + targetSavings.createGoal(input), + ); + const renewBy = goal.allowance?.renewBy; + element('goal-result').replaceChildren( + paragraph(`Goal ${goal.result} created.`), + writeLine(goal), + ...(renewBy ? [paragraph(`Renew by ${renewBy.toUTCString()}.`)] : []), + ); + await afterWrite(); + }); + + return { load }; +} diff --git a/src/ui.ts b/src/ui.ts index 12f7444..21cb68a 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,4 +1,4 @@ -import { LoktinError } from '@loktin/sdk'; +import { fromBaseUnits, LoktinError } from '@loktin/sdk'; /** Plain text for any error: SDK errors with their code, wallet errors too. */ function errorText(error: unknown): string { @@ -34,3 +34,80 @@ export function element(id: string): HTMLElement { if (!found) throw new Error(`Missing element: ${id}.`); return found; } + +export function field(id: string): HTMLInputElement { + return element(id) as HTMLInputElement; +} + +export function usdc(units: bigint): string { + return `${fromBaseUnits(units)} USDC`; +} + +export function paragraph(text: string, className?: string): HTMLElement { + const created = document.createElement('p'); + created.textContent = text; + if (className) created.className = className; + return created; +} + +export function errorParagraph(): HTMLElement { + const created = paragraph('', 'error'); + created.role = 'alert'; + created.hidden = true; + return created; +} + +export function details( + rows: readonly (readonly [string, string])[], +): HTMLElement { + const list = document.createElement('dl'); + for (const [term, value] of rows) { + const name = document.createElement('dt'); + name.textContent = term; + const text = document.createElement('dd'); + text.textContent = value; + list.append(name, text); + } + return list; +} + +/** Runs `run` with `button` disabled. Any failure shows in `error`. */ +export async function runAction( + button: HTMLButtonElement, + error: HTMLElement, + run: () => Promise, +): Promise { + button.disabled = true; + showError(error, undefined); + try { + await run(); + } catch (cause) { + showError(error, cause); + } finally { + button.disabled = false; + } +} + +export function onSubmit( + form: HTMLElement, + error: HTMLElement, + run: () => Promise, +): void { + form.addEventListener('submit', (event) => { + event.preventDefault(); + const button = form.querySelector( + 'button[type="submit"]', + ); + if (button) void runAction(button, error, run); + }); +} + +export function onClick( + button: HTMLElement, + error: HTMLElement, + run: () => Promise, +): void { + button.addEventListener('click', () => { + void runAction(button as HTMLButtonElement, error, run); + }); +} From a89997c4be22e8a90d0ff2094da7ba94e86c71c9 Mon Sep 17 00:00:00 2001 From: levai Date: Fri, 30 Oct 2026 13:16:00 +0100 Subject: [PATCH 2/2] docs: add the browser smoke checklist --- README.md | 14 ++++++++------ docs/smoke-test.md | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 docs/smoke-test.md diff --git a/README.md b/README.md index 52b9a68..1cf53ca 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,16 @@ A small web app that shows how an integrator uses the Loktin SDK, [`@loktin/sdk`](https://github.com/Lokt-In/loktin-sdk). It runs on Stellar testnet only. Test assets have no value. -This is the app's first part. It shows: +The app shows how to: -- connecting a wallet through Stellar Wallets Kit; -- XLM and USDC balances; -- the steps a new account needs: test XLM, the USDC trustline and test USDC; -- the network settings and the contract's minimum cadence, read through the SDK. +- connect a wallet through Stellar Wallets Kit; +- read XLM and USDC balances; +- take a new account through test XLM, the USDC trustline and test USDC; +- create Target Savings goals, deposit, manage the allowance, and preview and withdraw; +- enrol in Spend & Save, pay and save, read the withdrawal window, and preview and withdraw; +- list every transaction of the session, with its hash and ledger. -The Target Savings and Spend & Save flows come next. +The browser smoke checklist is in [docs/smoke-test.md](docs/smoke-test.md). The app never handles a secret key. All signing happens in the wallet. diff --git a/docs/smoke-test.md b/docs/smoke-test.md new file mode 100644 index 0000000..b460877 --- /dev/null +++ b/docs/smoke-test.md @@ -0,0 +1,37 @@ +# Browser smoke test + +The owner runs this checklist by hand. It checks the app and the SDK in a real browser, with a real wallet, on testnet. + +Before you start: + +- Run it on a UTC day from 1 to 28. A Spend & Save withdrawal only works on the withdrawal day, and that day can only be 1 to 28. +- Use a browser with Freighter set to testnet and a fresh test account. +- Copy each hash from the "This session" list in the app. + +Record these: + +- Date (UTC): +- Browser and version: +- Freighter version: + +| Step | Action | Hash | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------- | ---- | +| 1 | Connect the wallet. | | +| 2 | Get test XLM. | | +| 3 | Add the USDC trustline. | | +| 4 | Get at least 10 test USDC from the faucet. | | +| 5 | Create goal A: target 5 USDC, deposit 1 USDC, cadence 1 day, deadline 7 days ahead, penalty 500 basis points. Note `renewBy` if shown. | | +| 6 | Deposit 1 USDC into goal A by hand. | | +| 7 | Check goal A's allowance status. | | +| 8 | Create goal B: target 2 USDC, deposit 1 USDC, cadence 1 day, deadline 3 days ahead, penalty 1,000 basis points. | | +| 9 | Deposit 1 USDC into goal B. | | +| 10 | Preview goal B's withdrawal, then withdraw it. Note the penalty and fees shown. | | +| 11 | Enrol in Spend & Save: 1,000 basis points, destination balance, withdrawal day set to today's UTC day. | | +| 12 | Pay and save 2 USDC to `GCHOME3M4KSLKMW2QPWCFRCCKJDQBF6VKWU5JOFZMJV2QA6WYOA2TCDQ`. Note the amount saved. | | +| 13 | Check that the window shows as open today. | | +| 14 | Withdraw 0.1 USDC from Spend & Save. | | + +Afterwards: + +- Goal A stays open, so the Evidence keeper collects its next deposit the following day. +- Every hash gets checked on chain.