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.
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
+
+ Target Savings
+ Save toward a target by a deadline. At most 20 open goals.
+
+
+
+ Your goals
+ Refresh goals
+
+
+
+
+
+
+ Spend & Save
+
+ Each spend saves a share. A skim never exceeds the spend and never
+ blocks the payment.
+
+
+ Refresh status
+
+
+
+
+
+
+
+
Withdrawal window
+
+
+
+
+
+
Close
+
Your balance is zero, so you can close this enrolment.
+
Close enrolment
+
+
+
+
+
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);
+ });
+}