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
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
37 changes: 37 additions & 0 deletions docs/smoke-test.md
Original file line number Diff line number Diff line change
@@ -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.
171 changes: 171 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ <h1>Loktin SDK example</h1>
<p class="notice">Testnet only. Test assets have no value.</p>
<p id="wallet-error" class="error" role="alert" hidden></p>

<section aria-labelledby="session-heading">
<h2 id="session-heading">This session</h2>
<p id="session-empty">No transactions yet.</p>
<ol id="session-list" hidden></ol>
</section>

<section aria-labelledby="account-heading">
<h2 id="account-heading">Account</h2>
<p id="account-address">Connect a wallet to see your balances.</p>
Expand All @@ -34,6 +40,171 @@ <h2 id="onboarding-heading">Get started</h2>
<div id="onboarding-step"></div>
</section>

<section
id="target-savings"
aria-labelledby="target-savings-heading"
hidden
>
<h2 id="target-savings-heading">Target Savings</h2>
<p>Save toward a target by a deadline. At most 20 open goals.</p>

<form id="goal-form">
<h3>Create a goal</h3>
<label>
Target (USDC)
<input id="goal-target" inputmode="decimal" required />
</label>
<label>
Deposit size (USDC)
<input id="goal-deposit" inputmode="decimal" required />
</label>
<label>
Cadence (days)
<input id="goal-cadence" type="number" step="1" required />
</label>
<p id="goal-cadence-hint" class="hint" hidden></p>
<label>
Deadline (starts at 00:00 UTC on this date)
<input id="goal-deadline" type="date" required />
</label>
<label>
Rule
<select id="goal-rule">
<option value="penalty">Penalty</option>
<option value="lock">Lock</option>
</select>
</label>
<label id="goal-penalty-field">
Penalty (basis points, 0 to 1,000)
<input
id="goal-penalty"
type="number"
min="0"
max="1000"
step="1"
required
/>
</label>
<p id="goal-lock-hint" class="hint" hidden>
No withdrawal until the target is reached or the deadline passes.
</p>
<div id="goal-breakdown"></div>
<button type="submit">Create goal</button>
<p id="goal-error" class="error" role="alert" hidden></p>
<div id="goal-result"></div>
</form>

<h3>Your goals</h3>
<button id="goals-refresh" type="button">Refresh goals</button>
<p id="goals-error" class="error" role="alert" hidden></p>
<div id="goals-result"></div>
<div id="goals"></div>
</section>

<section id="spend-save" aria-labelledby="spend-save-heading" hidden>
<h2 id="spend-save-heading">Spend &amp; Save</h2>
<p>
Each spend saves a share. A skim never exceeds the spend and never
blocks the payment.
</p>
<div id="spend-status"></div>
<button id="spend-refresh" type="button">Refresh status</button>
<p id="spend-error" class="error" role="alert" hidden></p>

<form id="enrol-form">
<h3 id="enrol-heading">Enrol</h3>
<label>
Percentage (basis points, 100 to 5,000, optional)
<input
id="enrol-percent"
type="number"
min="100"
max="5000"
step="1"
/>
</label>
<label>
Round-up unit (USDC, optional)
<input id="enrol-round-up" inputmode="decimal" />
</label>
<p class="hint">Set the percentage, the round-up unit, or both.</p>
<label>
Monthly cap (USDC, optional)
<input id="enrol-cap" inputmode="decimal" />
</label>
<label>
Destination
<select id="enrol-destination">
<option value="balance">Spend &amp; Save balance</option>
</select>
</label>
<label>
Withdrawal day (1 to 28)
<input
id="enrol-day"
type="number"
min="1"
max="28"
step="1"
required
/>
</label>
<p class="hint">
The window is the whole UTC day. It can open up to 60 seconds early.
</p>
<p id="enrol-day-hint" class="hint" hidden>
A new withdrawal day takes effect next month.
</p>
<button id="enrol-submit" type="submit">Enrol</button>
<p id="enrol-error" class="error" role="alert" hidden></p>
<div id="enrol-result"></div>
</form>

<div id="spend-enrolled" hidden>
<form id="pay-form">
<h3>Pay and save</h3>
<label>
Payee address
<input id="pay-to" autocomplete="off" required />
</label>
<label>
Amount (USDC)
<input id="pay-amount" inputmode="decimal" required />
</label>
<button id="pay-preview" type="button">Preview</button>
<div id="pay-preview-result"></div>
<button id="pay-submit" type="submit" disabled>Pay and save</button>
<p id="pay-error" class="error" role="alert" hidden></p>
<div id="pay-result"></div>
</form>

<h3>Withdrawal window</h3>
<div id="spend-window"></div>

<form id="withdraw-form">
<h3>Withdraw</h3>
<label>
Amount (USDC)
<input id="withdraw-amount" inputmode="decimal" required />
</label>
<button id="withdraw-preview" type="button">Preview</button>
<div id="withdraw-preview-result"></div>
<button id="withdraw-submit" type="submit" disabled>
Withdraw
</button>
<p id="withdraw-error" class="error" role="alert" hidden></p>
<div id="withdraw-result"></div>
</form>

<div id="spend-close" hidden>
<h3>Close</h3>
<p>Your balance is zero, so you can close this enrolment.</p>
<button id="close-submit" type="button">Close enrolment</button>
<p id="close-error" class="error" role="alert" hidden></p>
</div>
</div>
</section>

<section aria-labelledby="network-heading">
<h2 id="network-heading">Network</h2>
<dl>
Expand Down
42 changes: 39 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -46,7 +77,11 @@ async function refresh(): Promise<void> {
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);
Expand All @@ -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) {
Expand All @@ -81,8 +118,7 @@ async function renderNetwork(): Promise<void> {

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';
Expand Down
14 changes: 9 additions & 5 deletions src/onboarding.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PendingError, TransactionFailedError } from '@loktin/sdk';
import {
BASE_FEE,
Operation,
Expand All @@ -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<void> {
async function addTrustline(address: string): Promise<Write> {
const server = new Server(TESTNET.rpcUrl);
const account = await server.getAccount(address);
const transaction = new TransactionBuilder(account, {
Expand All @@ -35,13 +37,15 @@ async function addTrustline(address: string): Promise<void> {

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[] {
Expand All @@ -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(() => {
Expand Down
Loading
Loading