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
10 changes: 9 additions & 1 deletion .vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,15 @@ export default defineConfig({
"/sdk/": [
{
text: "SDK",
items: [{ text: "Overview", link: "/sdk/" }],
items: [
{ text: "Overview", link: "/sdk/" },
{ text: "Quickstart", link: "/sdk/quickstart" },
{ text: "Target Savings", link: "/sdk/target-savings" },
{ text: "Spend & Save", link: "/sdk/spend-save" },
{ text: "Keeper", link: "/sdk/keeper" },
{ text: "Security and trust", link: "/sdk/security" },
{ text: "Testnet contracts", link: "/sdk/testnet" },
],
},
{
text: "API reference",
Expand Down
9 changes: 9 additions & 0 deletions sdk/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ Create one SDK instance for testnet with the user's address and a signer. Here `

<<< @/snippets/quickstart.ts#create

## Guides

- [Quickstart](/sdk/quickstart)
- [Target Savings](/sdk/target-savings)
- [Spend & Save](/sdk/spend-save)
- [Keeper](/sdk/keeper)
- [Security and trust](/sdk/security)
- [Testnet contracts](/sdk/testnet)

## API reference

See the [API reference](/sdk/api/).
34 changes: 34 additions & 0 deletions sdk/keeper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
description: "What a keeper does, the keeper functions in the Loktin SDK, and the keeper template."
---

# Keeper

A keeper collects the scheduled deposits of Target Savings goals when they are due.

Anyone may collect a due goal. There is no keeper role and no allowlist, so you can run a keeper without any permission from Loktin. A collect can only move the due amount from the goal owner's wallet into that owner's goal. The keeper's account needs only XLM for fees.

## The keeper functions

`@loktin/sdk/keeper` has the parts of a keeper:

- [`scanGoals`](/sdk/api/keeper/functions/scanGoals) finds the goals a keeper serves: the open goals of the given integrators, from the contract's `goal_created` and `goal_closed` events, plus the open goals of an optional list of owners. It reads the goals 200 at a time, in one RPC call each.
- [`isDue`](/sdk/api/keeper/functions/isDue) tells from a goal's stored data whether it can be collected now.
- [`runPass`](/sdk/api/keeper/functions/runPass) runs one pass: it scans, then collects each due goal. The contract's expected refusals, such as `NotDue` or `Shortfall`, are skips. Any other error stops the pass and is reported in `failure`.
- [`feeCeilingSigner`](/sdk/api/keeper/functions/feeCeilingSigner) wraps the keeper's signer so it refuses a transaction whose fee is above a ceiling. A refused collect is a skip.

<<< @/snippets/keeper.ts#run-pass

## Channels

`runPass` also takes a list of SDK instances, one per channel. One account gets about one transaction into each ledger, because its transactions must go one sequence number at a time.

A collect needs no signature but its sender's, so a channel is simply another keeper account with its own key and XLM for fees. Each channel takes the next due goal when it is free, so more channels collect more goals in the same time. The pass first checks that every channel's account exists, and sends nothing if one is missing.

## The keeper template

The keeper template is a small Node program built on `@loktin/sdk/keeper`. It runs one keeper pass and exits. It comes with a container image and examples for running it on a schedule. It works on Stellar testnet only for now.

- [The template](https://github.com/Lokt-In/loktin-sdk/tree/main/templates/keeper)
- [Its README](https://github.com/Lokt-In/loktin-sdk/blob/main/templates/keeper/README.md), with the full steps
- [The sizing results](https://github.com/Lokt-In/loktin-sdk/blob/main/docs/keeper-sizing.md)
47 changes: 47 additions & 0 deletions sdk/quickstart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
description: "Install the Loktin SDK, then create a first goal and a first pay-and-save on testnet."
---

# Quickstart

This page takes you from install to a first goal and a first pay-and-save. The SDK works on Stellar testnet only.

## Install

```sh
npm install @loktin/sdk@next @stellar/stellar-sdk
```

`@stellar/stellar-sdk` is a peer dependency. The SDK needs version 17.2.1 or a later 17.x.

## Create the instance

Create one SDK instance for testnet with the user's address and a signer. The signer's `signTransaction` comes from the user's wallet. The SDK never sees a secret key.

<<< @/snippets/quickstart.ts#create

Here `userAddress` and `wallet` come from your app. Every write acts for `publicKey`. See [`createLoktin`](/sdk/api/index/functions/createLoktin).

## Create the modules

Each module works for one integrator, which is your app's address, and takes your integrator fee in basis points.

<<< @/snippets/quickstart.ts#modules

Here `integratorAddress` comes from your app.

## Create a first goal

[`createGoal`](/sdk/api/target-savings/classes/TargetSavings#creategoal) creates a goal and gives you its id. Amounts are `bigint` base units. USDC has 7 decimals, and [`toBaseUnits`](/sdk/api/index/functions/toBaseUnits) converts from a decimal string.

<<< @/snippets/quickstart.ts#first-goal

By default, the goal also approves an allowance for its scheduled deposits. See the [Target Savings guide](/sdk/target-savings).

## Enrol and pay

[`enroll`](/sdk/api/spend-save/classes/SpendSave#enroll) sets how much of each spend is saved. [`payAndSave`](/sdk/api/spend-save/classes/SpendSave#payandsave) pays an address and saves the skim in the same transaction.

<<< @/snippets/quickstart.ts#first-spend

Here `shopAddress` comes from your app. See the [Spend & Save guide](/sdk/spend-save).
82 changes: 82 additions & 0 deletions sdk/security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
description: "Roles, pause, upgrades, fees and audit status of the Loktin SDK's contracts."
---

# Security and trust

This page says who can do what on the Loktin contracts, and what no one can do.

No one but the user moves the user's money. Deposits, payments and withdrawals need the user's signature, and scheduled deposits are collected only within the allowance the user approved.

## Roles on testnet

On testnet each role is held by a separate key that the project owner controls. The addresses are on the [Testnet contracts](/sdk/testnet#role-addresses) page.

## What each role can do

| Role | Can |
| --------- | ----------------------------------------------------------------------------------- |
| Admin | Grant and revoke roles, and nothing else. |
| Fee-admin | Set the Loktin fee rate for future goals, within the cap, and the address Loktin fees are paid to. It holds no other power. |
| Pauser | Pause and unpause. |
| Upgrader | Replace the contract code. |

No role can move, redirect or freeze user funds.

Anyone may collect a due goal, and only the due amount moves, into the owner's own goal. See the [keeper guide](/sdk/keeper).

## Pause

Pausing blocks creating goals, enrolling, deposits, scheduled collections, skims and pay-and-save.

It never blocks withdrawals or reads. Withdrawals still follow each goal's or enrolment's rules.

## Upgrades

The upgrader role can replace the contract code. The upgrader is the one remaining trusted power over the contracts.

Each upgrade needs:

- a decision note;
- a storage-compatibility test against the previous version;
- a new version in the contract metadata.

Before any mainnet deployment, upgrades will also need a multisig and a public waiting period.

## Fees and penalties

Each is capped in the contracts:

| Charge | Cap | Received by |
| ---------------------------------------- | ------------------------------------ | ----------------------- |
| Loktin fee | 0% today, and never more than 1% | Loktin's fee recipient |
| Integrator fee | At most 1% | The integrator |
| Early-withdrawal penalty on a Target Savings goal | At most 10% | The integrator |

Each goal and enrolment keeps the fee rates in force when it was created. A new Loktin rate applies only to goals and enrolments created afterwards.

## Claiming fees

A withdrawal pays the user their share. The penalty and fees accrue to the integrator and Loktin's fee recipient, who each claim them, so no one else can block a user's withdrawal.

The integrator claims with `claimFees`, and `accruedFees` shows what is owed. See [`claimFees`](/sdk/api/target-savings/classes/TargetSavings#claimfees) and [`accruedFees`](/sdk/api/target-savings/classes/TargetSavings#accruedfees). Fees accrue separately in each contract.

Claiming needs the integrator's own key: create a separate SDK instance with the integrator's address as `publicKey` and its wallet as the signer. The integrator's account also needs a trustline for the token at the time of the claim, or the payment fails and the fees stay where they are. Unclaimed fees never expire.

<<< @/snippets/security.ts#claim-fees

## Rounding

Every fee and penalty calculation rounds any fraction down, in the user's favour.

## There is no yield

There is no yield. Savings are held as they are deposited. The contracts' yield slot is switched off, and the constructor refuses any yield source.

## Libraries and audit status

The contracts use OpenZeppelin stellar-contracts 0.8.0, which has been audited. The Loktin contracts themselves have not been audited.

## Testnet only

The contracts have no mainnet deployment, and the SDK refuses the public network.
59 changes: 59 additions & 0 deletions sdk/spend-save.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
description: "Save a share of each spend, and withdraw once a month, with the Loktin SDK."
---

# Spend & Save

Each time the user spends, a share of the spend is saved, either to a Spend & Save balance or into a Target Savings goal. The balance can be withdrawn on one day each month.

## Enrol

[`enroll`](/sdk/api/spend-save/classes/SpendSave#enroll) sets how much of each spend is saved.

<<< @/snippets/spend-save.ts#enroll

The settings are:

- **Percentage:** `percentBps`, 100 to 5,000 basis points of the spend.
- **Round-up:** `roundUpUnit`, which rounds the spend up to a multiple of this unit, in base units. Set this, the percentage, or both.
- **Monthly cap:** `monthlyCap`, the most saved in one UTC month. It is optional. Once it is reached, skims shrink to fit it. The payment is never blocked by the cap.
- **Destination:** `{ kind: 'balance' }`, or `{ kind: 'goal', goalId }` for one of the user's open goals. A skim sent to a goal follows that goal's rules.
- **Withdrawal day:** `withdrawalDay`, a day of the month from 1 to 28, in UTC.

A user has one enrolment per integrator, and at most 5 in all.

## Pay and save

[`payAndSave`](/sdk/api/spend-save/classes/SpendSave#payandsave) pays a G, M or C address and saves the skim in the same transaction.

<<< @/snippets/spend-save.ts#pay

A skim never exceeds the spend. A skim never blocks a payment: if the wallet cannot cover the payment plus the skim, the skim shrinks to what remains, down to zero. The result's `shortfall` shows how much the wallet could not cover.

For a spend made by other means, [`saveForSpend`](/sdk/api/spend-save/classes/SpendSave#saveforspend) saves the skim on its own. [`previewSkim`](/sdk/api/spend-save/classes/SpendSave#previewskim) shows how a spend would be skimmed.

## The withdrawal window

The withdrawal window is the whole UTC day of the user's withdrawal day, every month. It can open up to 60 seconds early in real time.

[`nextWindow`](/sdk/api/spend-save/classes/SpendSave#nextwindow) gives the current window if one is open, otherwise the next one, in UTC. Its `end` is the first second after the window. [`windowInTimeZone`](/sdk/api/index/functions/windowInTimeZone) formats it in the device's time zone, or one you name.

<<< @/snippets/spend-save.ts#window

## Change settings

[`updateSettings`](/sdk/api/spend-save/classes/SpendSave#updatesettings) replaces the skim settings, cap and destination at once. They may change at any time. Fee rates never change.

<<< @/snippets/spend-save.ts#settings

A new withdrawal day takes effect from next month. Until then the current month keeps the old day. [`enrollment`](/sdk/api/spend-save/classes/SpendSave#enrollment) shows the day that applies now as `effectiveWithdrawalDay`. That value is for display only: at the edge of a month the chain decides.

## Withdraw

[`withdraw`](/sdk/api/spend-save/classes/SpendSave#withdraw) withdraws an amount from the balance. Within the window, the user may withdraw any amount from 1 base unit up to the balance. The chain refuses it outside the window. [`previewWithdraw`](/sdk/api/spend-save/classes/SpendSave#previewwithdraw) shows how a withdrawal would split, and does not check the window.

<<< @/snippets/spend-save.ts#withdraw

The user receives `toUser`. The integrator fee accrues to the integrator, and the Loktin fee to Loktin's fee recipient. Spend & Save has no penalty.

[`close`](/sdk/api/spend-save/classes/SpendSave#close) closes the enrolment once its balance is zero.
96 changes: 96 additions & 0 deletions sdk/target-savings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
description: "Create savings goals, manage the allowance, and withdraw with the Loktin SDK."
---

# Target Savings

A goal saves toward a target by a deadline. Scheduled deposits are collected by the contract, and the user can also deposit by hand.

## Create a goal

[`createGoal`](/sdk/api/target-savings/classes/TargetSavings#creategoal) creates a goal for `publicKey` and gives you its id. Ids are not sequential.

<<< @/snippets/target-savings.ts#create-goal

### Withdrawal rules

Each goal has one rule, chosen when it is created:

- **Penalty:** the user may withdraw early and pays a penalty of up to 1,000 basis points (10%). The penalty goes to the integrator.
- **Lock:** there is no withdrawal until the target is reached or the deadline passes.

Once the target is reached, or from the deadline second onwards, a withdrawal carries no penalty. Fees still apply.

## Amounts

Amounts are `bigint` base units. USDC has 7 decimals. [`toBaseUnits`](/sdk/api/index/functions/toBaseUnits) and [`fromBaseUnits`](/sdk/api/index/functions/fromBaseUnits) convert to and from decimal strings.

## The allowance

An owner has one token allowance for Target Savings, shared by all their goals. Each approval replaces it whole.

`createGoal` takes an `allowance` option:

- **`'auto'`** is the default. The SDK approves what the scheduled deposits of all the owner's open goals need, the new one included.
- **`'none'`** approves nothing and leaves the current allowance as it is.
- **`{ amount, expiryLedger }`** approves exactly that, and replaces the allowance of all the owner's goals.

Create an owner's goals one at a time, so each approval sees the goals before it.

The allowance covers the deposits due before it expires. It expires one day after the latest deadline of those goals, unless the network's longest allowed time is shorter. In that case the result has a `renewBy` date, 7 days before the allowance expires.

Call [`renewAllowance`](/sdk/api/target-savings/classes/TargetSavings#renewallowance) before that date, or later scheduled deposits fail for lack of allowance. It covers all the owner's open goals, as `'auto'` does.

## Manual deposits

[`deposit`](/sdk/api/target-savings/classes/TargetSavings#deposit) moves an amount from `publicKey` into their goal.

<<< @/snippets/target-savings.ts#deposit

## Check before signing

[`creationBreakdown`](/sdk/api/target-savings/functions/creationBreakdown) shows what an early withdrawal of the whole target would pay out, before the user signs. It makes no network call.

<<< @/snippets/target-savings.ts#breakdown

[`allowanceStatus`](/sdk/api/target-savings/classes/TargetSavings#allowancestatus) says whether the next scheduled deposit would be short on allowance. The other goals' collections use the same allowance. It does not check the owner's balance.

<<< @/snippets/target-savings.ts#status

## Find goals

There is no paging by number. Use the id that `createGoal` gives you. To find goals, do one of these:

- Call [`userGoals`](/sdk/api/target-savings/classes/TargetSavings#usergoals) for each of your own users, at most 20 goals each.
- Read the contract's `goal_created` and `goal_closed` events into your own store.

<<< @/snippets/target-savings.ts#find-goals

## Withdraw

[`withdraw`](/sdk/api/target-savings/classes/TargetSavings#withdraw) withdraws the whole goal and closes it. [`previewWithdraw`](/sdk/api/target-savings/classes/TargetSavings#previewwithdraw) shows how a withdrawal would split right now.

<<< @/snippets/target-savings.ts#withdraw

The user receives `toUser`. The penalty and the integrator fee accrue to the integrator, and the Loktin fee to Loktin's fee recipient. See [Security and trust](/sdk/security#claiming-fees).

Under a lock rule, a withdrawal before the target or deadline is refused while the goal holds any principal. A goal with nothing deposited can be closed at any time, under any rule.

## Errors

Every error the SDK throws is a [`LoktinError`](/sdk/api/index/classes/LoktinError) with a stable `code`:

| Code | Meaning |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `INVALID_INPUT` | An input was refused before any network call. |
| `CONTRACT_ERROR` | Target Savings or Spend & Save refused the call with one of its documented codes. |
| `TOKEN_ERROR` | The token contract refused the call, for example for a low balance or a missing trustline. |
| `UNKNOWN_CONTRACT_ERROR` | A contract error the SDK cannot name. |
| `SIGNER_REFUSED` | The wallet refused to sign, or failed while signing. |
| `NETWORK` | The RPC could not be reached, or answered with an error, after the SDK's retries: 3 for a read, and 3 for each step of a write before signing. |
| `PENDING` | The transaction was sent and its outcome is not known yet. Look it up by hash; do not resend. |
| `TRANSACTION_FAILED` | The transaction was sent and failed. |

Each error is reported against the contract that raised it. The contract codes are listed in [docs/errors.md](https://github.com/Lokt-In/loktin-sdk/blob/main/docs/errors.md).

<<< @/snippets/target-savings.ts#errors
Loading
Loading