diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 5ed7939..d096959 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -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", diff --git a/sdk/index.md b/sdk/index.md index d4040f8..daabdb9 100644 --- a/sdk/index.md +++ b/sdk/index.md @@ -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/). diff --git a/sdk/keeper.md b/sdk/keeper.md new file mode 100644 index 0000000..416bd5c --- /dev/null +++ b/sdk/keeper.md @@ -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) diff --git a/sdk/quickstart.md b/sdk/quickstart.md new file mode 100644 index 0000000..c87c90a --- /dev/null +++ b/sdk/quickstart.md @@ -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). diff --git a/sdk/security.md b/sdk/security.md new file mode 100644 index 0000000..013a2d5 --- /dev/null +++ b/sdk/security.md @@ -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. diff --git a/sdk/spend-save.md b/sdk/spend-save.md new file mode 100644 index 0000000..a657891 --- /dev/null +++ b/sdk/spend-save.md @@ -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. diff --git a/sdk/target-savings.md b/sdk/target-savings.md new file mode 100644 index 0000000..868e884 --- /dev/null +++ b/sdk/target-savings.md @@ -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 diff --git a/sdk/testnet.md b/sdk/testnet.md new file mode 100644 index 0000000..4193101 --- /dev/null +++ b/sdk/testnet.md @@ -0,0 +1,69 @@ +--- +description: "Contract ids, releases, role addresses and attestation checks for Loktin's testnet deployment." +--- + +# Testnet contracts + +These are the contracts the SDK uses on Stellar testnet. There is no mainnet deployment. + +## Network + +| Setting | Value | +| ------------------ | ----------------------------------- | +| Network passphrase | `Test SDF Network ; September 2015` | +| RPC | `https://soroban-testnet.stellar.org` | + +## Contracts + +| Contract | Id | +| -------------- | -- | +| Target Savings | [`CCJH2IRI7JVZ7JV7J4XLUPVIPVMLG2HB7OBDQC2F3XLPVOVBH3QQVP4Z`](https://stellar.expert/explorer/testnet/contract/CCJH2IRI7JVZ7JV7J4XLUPVIPVMLG2HB7OBDQC2F3XLPVOVBH3QQVP4Z) | +| Spend & Save | [`CCUFRRPCCE7YVKTG3FWE2RAW5ACMU7KVACTVKDM452C5QFMABAXQTTCN`](https://stellar.expert/explorer/testnet/contract/CCUFRRPCCE7YVKTG3FWE2RAW5ACMU7KVACTVKDM452C5QFMABAXQTTCN) | +| USDC | [`CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA`](https://stellar.expert/explorer/testnet/contract/CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA) | + +USDC here is Circle's testnet USDC. + +## Live releases + +Both contracts run release [`contracts-v0.3.0`](https://github.com/Lokt-In/loktin-sdk/tree/contracts-v0.3.0), commit `a91b9eec00eb3a4af04cf3492f2b193e1328df28`. + +| Contract | Live Wasm hash | +| -------------- | -------------- | +| Target Savings | `ccafed3e94e9d9af6b3a72d31e85050bc357168552c8745a9314ea79e29e0b3e` | +| Spend & Save | `42c21931d32b9874f257991afc984be02552d4db343dade4b2e7e715a9a24200` | + +## Upgrade history + +The contracts started at `contracts-v0.1.0`. Each later release upgraded them in place, so the ids stayed the same. + +| Release | Commit | Target Savings Wasm hash | Spend & Save Wasm hash | +| ------- | ------ | ------------------------ | ---------------------- | +| [`contracts-v0.1.0`](https://github.com/Lokt-In/loktin-sdk/tree/contracts-v0.1.0) | `d5fc95680bd443a5ee41020afd2b43eb9b7a91ca` | `5b67b75f7504bb748e7b4c891cbf956d381d923431c591a2e6bde072d50d047e` | `1e655573744740bfbbebbb04b7c54c9058e979c853bc23fe4f71431cc3999183` | +| [`contracts-v0.2.0`](https://github.com/Lokt-In/loktin-sdk/tree/contracts-v0.2.0) | `475217b6d679b199e54dd5efde29a63ec38c39ef` | `9686da20b05d891a317272f92fce0fb2e9d75fa7f67ec5934452be827fe9a0a9` | `d7a7bb028dca1db88cf78e5514ea99a48c8f373010c154862958153478020f95` | +| [`contracts-v0.3.0`](https://github.com/Lokt-In/loktin-sdk/tree/contracts-v0.3.0) | `a91b9eec00eb3a4af04cf3492f2b193e1328df28` | `ccafed3e94e9d9af6b3a72d31e85050bc357168552c8745a9314ea79e29e0b3e` | `42c21931d32b9874f257991afc984be02552d4db343dade4b2e7e715a9a24200` | + +## Role addresses + +| Role | Address | +| ------------- | ------- | +| Admin | `GBLRCDODEBVISJZKU64JJDPJK6Y5VUYS5DSNCBMTJWC3RMDYLAQ47BHH` | +| Fee-admin | `GDTK23FN4KUACFLXWBXJA74VCBWJREXMNDSG4JVIA66DRQKG5CTCTJ6A` | +| Pauser | `GDS4NBNFAO4LGGETP2WD4WU4NDPVI3CT2XOKJRDF5B354TZGM3Z4LOWF` | +| Upgrader | `GBSSL5XPSTNFJ2J7KT3STB6FCBHED7ZY3HDFJHL6GUQPPDGXZ7CH2KPJ` | +| Fee recipient | `GBDE532O3GILO7HDUUVTAKFLI44CJTPG7X4BVSDOZSRFJCUZEZYY44RT` | + +## Minimum cadence + +The shortest cadence a goal may use on this instance is 86,400 seconds (1 day). [`minCadence`](/sdk/api/target-savings/classes/TargetSavings#mincadence) reads it from the contract. + +## Check an attestation + +Each contract's live Wasm hash has a GitHub artifact attestation in `Lokt-In/loktin-sdk`. With the GitHub CLI, download the Wasm files from the `contracts-v0.3.0` release run, then check each one. GitHub keeps this artifact until 26 January 2027. + +```sh +gh run download 40702034861 --repo Lokt-In/loktin-sdk -n contracts-wasm -D +gh attestation verify /loktin_target_savings.wasm -R Lokt-In/loktin-sdk +gh attestation verify /loktin_spend_save.wasm -R Lokt-In/loktin-sdk +``` + +There are GitHub release pages for the SDK only, not for the contracts. See [docs/deploy.md](https://github.com/Lokt-In/loktin-sdk/blob/main/docs/deploy.md) for the full steps. diff --git a/snippets/keeper.ts b/snippets/keeper.ts new file mode 100644 index 0000000..33b3cb4 --- /dev/null +++ b/snippets/keeper.ts @@ -0,0 +1,32 @@ +import type { LoktinSigner } from '@loktin/sdk'; + +declare const keeperAddress: string; +declare const keeperWallet: LoktinSigner; +declare const integratorAddress: string; + +// #region run-pass +import { createLoktin, TESTNET } from '@loktin/sdk'; +import { feeCeilingSigner, runPass } from '@loktin/sdk/keeper'; + +// The keeper's own SDK instance, with its address and its wallet. +const keeperLoktin = createLoktin({ + network: 'testnet', + publicKey: keeperAddress, + signer: { + // Refuse any transaction whose fee is above 1 XLM. + signTransaction: feeCeilingSigner( + (xdr, opts) => keeperWallet.signTransaction(xdr, opts), + 10_000_000n, + TESTNET.passphrase, + ), + }, +}); + +const pass = await runPass(keeperLoktin, { integrators: [integratorAddress] }); +for (const result of pass.results) { + console.log(result.goalId, result.action); +} +if (pass.failure !== undefined) { + console.log('The pass stopped:', pass.failure.code, pass.failure.message); +} +// #endregion run-pass diff --git a/snippets/quickstart.ts b/snippets/quickstart.ts index cb7578f..bd59a0f 100644 --- a/snippets/quickstart.ts +++ b/snippets/quickstart.ts @@ -2,6 +2,8 @@ import type { LoktinSigner } from '@loktin/sdk'; declare const userAddress: string; declare const wallet: LoktinSigner; +declare const integratorAddress: string; +declare const shopAddress: string; // #region create import { createLoktin } from '@loktin/sdk'; @@ -14,3 +16,40 @@ const loktin = createLoktin({ }, }); // #endregion create + +// #region modules +import { SpendSave } from '@loktin/sdk/spend-save'; +import { TargetSavings } from '@loktin/sdk/target-savings'; + +const options = { integrator: integratorAddress, integratorFeeBps: 50 }; +const targetSavings = new TargetSavings(loktin, options); +const spendSave = new SpendSave(loktin, options); +// #endregion modules + +// #region first-goal +import { toBaseUnits } from '@loktin/sdk'; + +const goal = await targetSavings.createGoal({ + target: toBaseUnits('500'), + deadline: new Date('2027-06-30T00:00:00Z'), + cadenceSeconds: 7 * 24 * 60 * 60, + depositSize: toBaseUnits('20'), + rule: { kind: 'penalty', bps: 500 }, +}); +console.log('New goal id:', goal.result); +// #endregion first-goal + +// #region first-spend +import { fromBaseUnits } from '@loktin/sdk'; + +await spendSave.enroll({ + skim: { percentBps: 500, roundUpUnit: toBaseUnits('1') }, + monthlyCap: toBaseUnits('100'), + destination: { kind: 'goal', goalId: goal.result }, + withdrawalDay: 15, +}); + +const paid = await spendSave.payAndSave(shopAddress, toBaseUnits('12.50')); +const saved = paid.result.toBalance + paid.result.toGoal; +console.log('Saved', fromBaseUnits(saved), 'USDC'); +// #endregion first-spend diff --git a/snippets/security.ts b/snippets/security.ts new file mode 100644 index 0000000..b1f2980 --- /dev/null +++ b/snippets/security.ts @@ -0,0 +1,32 @@ +import type { IntegratorOptions, LoktinSigner } from '@loktin/sdk'; + +declare const integratorAddress: string; +declare const integratorWallet: LoktinSigner; +declare const options: IntegratorOptions; + +// #region claim-fees +import { createLoktin, fromBaseUnits } from '@loktin/sdk'; +import { SpendSave } from '@loktin/sdk/spend-save'; +import { TargetSavings } from '@loktin/sdk/target-savings'; + +// The integrator's own SDK instance, with its address and its wallet. +const integratorLoktin = createLoktin({ + network: 'testnet', + publicKey: integratorAddress, + signer: { + signTransaction: (xdr, opts) => integratorWallet.signTransaction(xdr, opts), + }, +}); + +// Fees accrue separately in each contract. +for (const savings of [ + new TargetSavings(integratorLoktin, options), + new SpendSave(integratorLoktin, options), +]) { + const owed = await savings.accruedFees(); + if (owed > 0n) { + const claimed = await savings.claimFees(); + console.log('Claimed', fromBaseUnits(claimed.result), 'USDC'); + } +} +// #endregion claim-fees diff --git a/snippets/spend-save.ts b/snippets/spend-save.ts new file mode 100644 index 0000000..8ac640f --- /dev/null +++ b/snippets/spend-save.ts @@ -0,0 +1,49 @@ +import type { SpendSave } from '@loktin/sdk/spend-save'; +import type { CreatedGoal } from '@loktin/sdk/target-savings'; + +declare const spendSave: SpendSave; +declare const goal: CreatedGoal; +declare const shopAddress: string; + +// #region enroll +import { toBaseUnits } from '@loktin/sdk'; + +await spendSave.enroll({ + skim: { percentBps: 500, roundUpUnit: toBaseUnits('1') }, + monthlyCap: toBaseUnits('100'), + destination: { kind: 'goal', goalId: goal.result }, + withdrawalDay: 15, +}); +// #endregion enroll + +// #region pay +import { fromBaseUnits } from '@loktin/sdk'; + +const paid = await spendSave.payAndSave(shopAddress, toBaseUnits('12.50')); +const saved = paid.result.toBalance + paid.result.toGoal; +console.log('Saved', fromBaseUnits(saved), 'USDC'); +// #endregion pay + +// #region window +import { windowInTimeZone } from '@loktin/sdk'; + +const next = windowInTimeZone(await spendSave.nextWindow()); +console.log(`You can withdraw from ${next.start} until ${next.end}.`); +// #endregion window + +// #region settings +await spendSave.updateSettings({ + skim: { percentBps: 300 }, + monthlyCap: toBaseUnits('50'), + destination: { kind: 'balance' }, + withdrawalDay: 20, +}); +// #endregion settings + +// #region withdraw +const split = await spendSave.previewWithdraw(toBaseUnits('25')); +console.log('The user receives', fromBaseUnits(split.toUser), 'USDC'); + +const withdrawal = await spendSave.withdraw(toBaseUnits('25')); +console.log('Paid', fromBaseUnits(withdrawal.result.toUser), 'USDC'); +// #endregion withdraw diff --git a/snippets/target-savings.ts b/snippets/target-savings.ts new file mode 100644 index 0000000..aea36e8 --- /dev/null +++ b/snippets/target-savings.ts @@ -0,0 +1,80 @@ +import type { TargetSavings } from '@loktin/sdk/target-savings'; + +declare const targetSavings: TargetSavings; + +// #region create-goal +import { toBaseUnits } from '@loktin/sdk'; + +const goal = await targetSavings.createGoal({ + target: toBaseUnits('500'), + deadline: new Date('2027-06-30T00:00:00Z'), + cadenceSeconds: 7 * 24 * 60 * 60, + depositSize: toBaseUnits('20'), + rule: { kind: 'penalty', bps: 500 }, +}); +console.log('New goal id:', goal.result); +if (goal.allowance?.renewBy !== undefined) { + console.log('Renew the allowance by', goal.allowance.renewBy); +} + +// Later, before `renewBy`: +await targetSavings.renewAllowance(goal.result); +// #endregion create-goal + +// #region deposit +await targetSavings.deposit(goal.result, toBaseUnits('5')); +// #endregion deposit + +// #region breakdown +import { fromBaseUnits } from '@loktin/sdk'; +import { creationBreakdown } from '@loktin/sdk/target-savings'; + +const breakdown = creationBreakdown({ + target: toBaseUnits('500'), + rule: { kind: 'penalty', bps: 500 }, + loktinFeeBps: await targetSavings.loktinFeeBps(), + integratorFeeBps: targetSavings.integratorFeeBps, +}); +console.log('An early withdrawal pays', fromBaseUnits(breakdown.toUser), 'USDC'); +// #endregion breakdown + +// #region status +const status = await targetSavings.allowanceStatus(goal.result); +if (status.short) { + await targetSavings.renewAllowance(goal.result); +} +// #endregion status + +// #region find-goals +const goals = await targetSavings.userGoals(); +for (const open of goals) { + console.log(open.id, fromBaseUnits(open.principal), 'USDC'); +} +// #endregion find-goals + +// #region withdraw +const split = await targetSavings.previewWithdraw(goal.result); +console.log('The user receives', fromBaseUnits(split.toUser), 'USDC'); + +const withdrawal = await targetSavings.withdraw(goal.result); +console.log('Paid', fromBaseUnits(withdrawal.result.toUser), 'USDC'); +// #endregion withdraw + +// #region errors +import { ContractError, LoktinError, PendingError } from '@loktin/sdk'; + +try { + await targetSavings.deposit(goal.result, toBaseUnits('5')); +} catch (error) { + if (error instanceof ContractError) { + console.log(error.errorName, error.description); + } else if (error instanceof PendingError) { + // The outcome is not known yet. Look it up by hash; do not send again. + console.log(error.hash, error.validUntil); + } else if (error instanceof LoktinError) { + console.log(error.code, error.message); + } else { + throw error; + } +} +// #endregion errors