Developer Guide

SetupPayment with memoTime-lock vaultCooling-off & vestingSEP-7 pay links

The wallet is source available at github.com/stellartimelock/stellartimelock-wallet. These examples use throwaway testnet accounts funded by Friendbot. Never paste a real secret key into sample code.

Setup

npm install @stellar/stellar-sdk@17

Save this as common.mjs. The other samples import from it.

import { Keypair, Networks, contract } from "@stellar/stellar-sdk";
export const RPC_URL = "https://soroban-testnet.stellar.org";
export const NETWORK = Networks.TESTNET;
export async function fundedKeypair() {
  const kp = Keypair.random();
  const r = await fetch(`https://friendbot.stellar.org?addr=${kp.publicKey()}`);
  if (!r.ok) throw new Error("friendbot failed: " + r.status);
  return kp;
}
export async function vaultClient(contractId, kp) {
  const { signTransaction } = contract.basicNodeSigner(kp, NETWORK);
  return contract.Client.from({ contractId, rpcUrl: RPC_URL, networkPassphrase: NETWORK, publicKey: kp.publicKey(), signTransaction });
}
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

Send XLM with a memo

Exchanges and many services need a memo to credit you. Memo.text holds at most 28 bytes (bytes, not characters, so emoji count extra); the SDK throws if you go over.

import { Horizon, TransactionBuilder, Operation, Asset, Memo, BASE_FEE } from "@stellar/stellar-sdk";
import { fundedKeypair, NETWORK } from "./common.mjs";
const horizon = new Horizon.Server("https://horizon-testnet.stellar.org");
const sender = await fundedKeypair();
const receiver = await fundedKeypair();
const memoText = "invoice 1042";
if (new TextEncoder().encode(memoText).length > 28) throw new Error("memo over 28 bytes");
const account = await horizon.loadAccount(sender.publicKey());
const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK })
  .addOperation(Operation.payment({ destination: receiver.publicKey(), asset: Asset.native(), amount: "12.5" }))
  .addMemo(Memo.text(memoText))
  .setTimeout(60)
  .build();
tx.sign(sender);
const res = await horizon.submitTransaction(tx);
console.log("paid, hash", res.hash);
const t = await horizon.transactions().transaction(res.hash).call();
console.log("memo on ledger:", t.memo_type, t.memo);
try { Memo.text("x".repeat(29)); console.log("NO THROW on 29 bytes"); } catch (e) { console.log("29-byte memo rejected:", e.message); }

Time-lock vault (xlm_vault)

Testnet contract CBIXRLC5GQ6O5UUJFVRIWZD77P5LUVJG3XGGUVS4EWHAJ6LXQZNKH35Z locks XLM until a Unix timestamp. Functions: create_vault, deposit, extend_lock, withdraw, get_vault, list_owned. The lock can only move later, never earlier, and withdraw fails until the unlock time passes. Amounts are in stroops (1 XLM = 10,000,000). Calls that return Result are unwrapped with .result.unwrap().

import { fundedKeypair, vaultClient, sleep } from "./common.mjs";
const VAULT = "CBIXRLC5GQ6O5UUJFVRIWZD77P5LUVJG3XGGUVS4EWHAJ6LXQZNKH35Z";
const owner = await fundedKeypair();
const vault = await vaultClient(VAULT, owner);
const XLM = 10_000_000n; // 1 XLM = 10,000,000 stroops
const now = BigInt(Math.floor(Date.now() / 1000));
let tx = await vault.create_vault({ owner: owner.publicKey(), name: "Rainy day", initial_deposit: 5n * XLM, unlock_timestamp: now + 20n });
const vaultId = (await tx.signAndSend()).result.unwrap(); // contract returns Result<u64>
console.log("vault id", vaultId);
await (await vault.deposit({ caller: owner.publicKey(), vault_id: vaultId, amount: 2n * XLM })).signAndSend();
await (await vault.extend_lock({ caller: owner.publicKey(), vault_id: vaultId, additional_seconds: 10n })).signAndSend();
const v = (await vault.get_vault({ vault_id: vaultId })).result.unwrap();
console.log("balance", v.balance, "unlocks", new Date(Number(v.unlock_timestamp) * 1000).toISOString());
const { result: ids } = await vault.list_owned({ owner: owner.publicKey() });
console.log("owned", ids);
try { await (await vault.withdraw({ caller: owner.publicKey(), vault_id: vaultId })).signAndSend(); console.log("EARLY WITHDRAW WORKED?!"); }
catch (e) { console.log("early withdraw refused:", String(e.message).slice(0, 120)); }
while (BigInt(Math.floor(Date.now() / 1000)) < v.unlock_timestamp + 6n) await sleep(2000);
await (await vault.withdraw({ caller: owner.publicKey(), vault_id: vaultId })).signAndSend();
const after = (await vault.get_vault({ vault_id: vaultId })).result.unwrap();
console.log("withdrawn", after.withdrawn, "balance", after.balance);

Cooling-off and vesting (multi_asset_vault_v2)

Testnet contract CAHAQAN6KYAQCJ34W6CVZJGMWRXKCZFHM32F37E4EFEIIC4636TD74BV holds any token; for XLM pass the native token contract CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC. A cooling-off vault makes every withdrawal wait: call initiate_withdrawal, wait cooling_off_duration_seconds, then withdraw (the owner can cancel_withdrawal in between). A vesting vault releases funds linearly between two timestamps; read get_unlocked_amount and claim with withdraw_vested.

import { fundedKeypair, vaultClient, sleep } from "./common.mjs";
const V2 = "CAHAQAN6KYAQCJ34W6CVZJGMWRXKCZFHM32F37E4EFEIIC4636TD74BV";
const XLM_TOKEN = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; // native XLM (testnet SAC)
const XLM = 10_000_000n;
const owner = await fundedKeypair();
const v2 = await vaultClient(V2, owner);
const me = owner.publicKey();
const now = () => BigInt(Math.floor(Date.now() / 1000));

// Cooling-off: every withdrawal must be announced, then wait.
let tx = await v2.create_cooling_off_vault({ owner: me, token: XLM_TOKEN, name: "Think twice", initial_deposit: 3n * XLM, cooling_off_duration_seconds: 15n });
const coolId = (await tx.signAndSend()).result.unwrap();
await (await v2.initiate_withdrawal({ caller: me, vault_id: coolId })).signAndSend();
let cv = (await v2.get_vault({ vault_id: coolId })).result.unwrap();
console.log("cooling id", coolId, "kind", cv.kind.tag, "initiated", cv.withdrawal_initiated_timestamp);
while (now() < cv.withdrawal_initiated_timestamp + cv.cooling_off_duration_seconds + 6n) await sleep(2000);
await (await v2.withdraw({ caller: me, vault_id: coolId })).signAndSend();
console.log("cooling-off withdrawn", (await v2.get_vault({ vault_id: coolId })).result.unwrap().withdrawn);

// Vesting: unlocks linearly between start and end.
const start = now();
tx = await v2.create_vesting_vault({ owner: me, token: XLM_TOKEN, name: "Payroll", initial_deposit: 6n * XLM, vesting_start_timestamp: start, vesting_end_timestamp: start + 60n });
const vestId = (await tx.signAndSend()).result.unwrap();
await sleep(20000);
const claimable = (await v2.get_unlocked_amount({ vault_id: vestId })).result.unwrap();
console.log("vesting id", vestId, "claimable", claimable);
await (await v2.withdraw_vested({ caller: me, vault_id: vestId, amount: claimable })).signAndSend();
const vv = (await v2.get_vault({ vault_id: vestId })).result.unwrap();
console.log("withdrawn_so_far", vv.withdrawn_so_far, "of", vv.total_locked_amount);

SEP-7 web+stellar:pay links

A SEP-7 link asks a wallet to prepare a payment the user then reviews and signs. Omit network_passphrase for mainnet.

import { Networks, StrKey } from "@stellar/stellar-sdk";
function payLink({ destination, amount, memo, msg, network = Networks.TESTNET }) {
  if (!StrKey.isValidEd25519PublicKey(destination)) throw new Error("bad destination");
  const q = [["destination", destination], ["amount", amount]];
  if (memo) q.push(["memo", memo], ["memo_type", "MEMO_TEXT"]);
  if (msg) q.push(["msg", msg]);
  q.push(["network_passphrase", network]);
  return "web+stellar:pay?" + q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
}
function parsePayLink(uri) {
  const m = /^web\+stellar:pay\?(.*)$/i.exec(uri.trim());
  if (!m) throw new Error("not a web+stellar:pay link");
  const p = new URLSearchParams(m[1]);
  return { destination: p.get("destination"), amount: p.get("amount"), memo: p.get("memo"), memoType: p.get("memo_type") ?? "MEMO_TEXT", msg: p.get("msg"), network: p.get("network_passphrase") ?? Networks.PUBLIC };
}
const link = payLink({ destination: "GAEPPLTQ4OIBG4J6DSI33FDYBB7YCKWOT36DNVLNOV65PIVSBAVSGXLS", amount: "12.5", memo: "invoice 1042", msg: "Coffee & cake" });
console.log(link);
console.log(parsePayLink(link));

← Back to home