From 1a4b07a5a84c7cc534149c9e02e4882854c4deca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Vargas?= <111903096+adrianvrj@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:07:26 -0600 Subject: [PATCH] Add create() to Stellar wallets A sponsored Stellar account starts with 0 XLM, and only execute() could create it, so the first XLM payment always failed with op_underfunded and an app that starts with a trustline had no public way to create the account. create() creates it without sending anything and resolves at once when the account is ready. addTrustline and invokeContract now point to it. The Stellar skill explains what the first call does. 0.5.3. --- CHANGELOG.md | 10 +++ package-lock.json | 4 +- package.json | 2 +- skills/cavos-stellar/SKILL.md | 21 +++-- .../stellar/CavosStellar.create.test.ts | 79 +++++++++++++++++++ src/chains/stellar/CavosStellar.ts | 24 ++++-- 6 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 src/chains/stellar/CavosStellar.create.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf1bb1..edd9a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.5.3 + +- **`create()` on Stellar.** `wallet.create()` creates the account without sending + anything, sponsored when an `appId` is set. Use it before `addTrustline` or + `invokeContract` on a new account. A sponsored account starts with 0 XLM, so + a first `execute` of XLM fails with `op_underfunded` unless XLM reaches the + account first; the account is still created. +- `addTrustline` and `invokeContract` on an undeployed account now say to call + `create()` first. + ## 0.5.2 Needs the current Cavos backend and recovery enclave (release v2). diff --git a/package-lock.json b/package-lock.json index 80559a6..eed186b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cavos/kit", - "version": "0.5.2", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cavos/kit", - "version": "0.5.2", + "version": "0.5.3", "license": "MIT", "dependencies": { "@expo/config-plugins": "^57.0.0", diff --git a/package.json b/package.json index a337c42..5443a7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cavos/kit", - "version": "0.5.2", + "version": "0.5.3", "description": "Embedded Stellar wallet SDK, embedded Solana wallet, and embedded Starknet wallet. Device-native self-custodial accounts for React Native and web.", "repository": { "type": "git", diff --git a/skills/cavos-stellar/SKILL.md b/skills/cavos-stellar/SKILL.md index 3f7b529..ca67565 100644 --- a/skills/cavos-stellar/SKILL.md +++ b/skills/cavos-stellar/SKILL.md @@ -116,12 +116,21 @@ wallet.status; // 'undeployed' | 'ready' | 'needs-device-approval' | `ready` | The account exists and this device can sign. | | `needs-device-approval` | The user has a wallet, but this device holds no key yet. Restore with an enclave sign-in or `approveDeviceWithPasskey()`. | -Connecting never creates the account. The first `execute` creates it (sponsored), then runs the payment. +Connecting never creates the account. On a new account: + +- `create()` creates it and sends nothing. The creation is sponsored, so the account starts with 0 XLM. + Call it first in an app that starts with a trustline or a Soroban call. +- `execute(amount, to)` creates it, then sends `amount` XLM from it. With 0 XLM the payment fails with + `op_underfunded`, but the account is created. +- `addTrustline` and `invokeContract` throw until the account exists. ## Actions ```ts -// Native XLM payment, amount in stroops (1 XLM = 10_000_000n). Sponsored by default. +// Create the account. Does nothing if it is already ready. +await wallet.create(); + +// Native XLM payment, amount in stroops (1 XLM = 10_000_000n). Fee sponsored by default. const hash = await wallet.execute(10_000_000n, 'GDEST…'); // Pay the fee from the account's own XLM instead of the relayer. @@ -153,9 +162,9 @@ with `wallet.signXdr`. See https://docs.cavos.xyz/docs/reserve. - **`appSalt`, `appId`, and `userId` are permanent.** Changing any of them silently moves every user to a new, empty wallet. Nothing errors. -- **A sponsored account holds 0 XLM.** The relayer sponsors reserves, so the account starts with no - lumens. An XLM payment needs XLM in the account first. Trustlines and Soroban calls do not. -- **`addTrustline` and `invokeContract` need `status === 'ready'`.** Only `execute` creates the account. +- **A sponsored account holds 0 XLM.** An XLM payment needs XLM in the account first. Trustlines and + Soroban calls do not, because the sponsor pays their reserves and fees. +- **Trustline-first apps call `create()` first.** `addTrustline` and `invokeContract` need `status === 'ready'`. - **Do not gate execution on `isReady`.** `undeployed` can execute; only `needs-device-approval` blocks. - **Second browser or device:** the user lands on `needs-device-approval` until the device restores the MasterDEK (enclave sign-in, or `approveDeviceWithPasskey()` if a passkey was added). @@ -167,7 +176,7 @@ with `wallet.signXdr`. See https://docs.cavos.xyz/docs/reserve. ## Version note -Written for `@cavos/kit` 0.5.2. Verified on Stellar mainnet on 2026-11-12: account +Written for `@cavos/kit` 0.5.3 (`create()` is new in 0.5.3). Verified on Stellar mainnet on 2026-11-12: account [GCTAQX…P5LG4Q](https://stellar.expert/explorer/public/account/GCTAQXKRGWROJ5UZBLUQ472PNNFOPQQOJBPCVSVCM5SKPKDOAVP5LG4Q), created by the Cavos relayer, with a sponsored Circle USDC trustline. diff --git a/src/chains/stellar/CavosStellar.create.test.ts b/src/chains/stellar/CavosStellar.create.test.ts new file mode 100644 index 0000000..45fe546 --- /dev/null +++ b/src/chains/stellar/CavosStellar.create.test.ts @@ -0,0 +1,79 @@ +import { Account, Keypair } from "@stellar/stellar-sdk"; +import { CavosStellar } from "./CavosStellar"; +import { StellarAdapter } from "./StellarAdapter"; +import { generateControlKey } from "./keys"; +import { LocalDeviceUnwrapKey } from "./DeviceUnwrapKey"; +import type { StellarRelayer } from "./StellarRelayer"; +import { KeypairControlKey } from "./WebCryptoControlKey"; + +const identity = { userId: "u1", appSalt: "app" }; + +function makeWallet(status: "undeployed" | "ready", relayer: StellarRelayer, control: Keypair, exists: boolean) { + const adapter = new StellarAdapter({ network: "stellar-testnet" }); + const fake = { loadAccount: async (addr: string) => new Account(addr, "100") }; + (adapter as unknown as { server: () => unknown }).server = () => fake; + jest.spyOn(adapter, "isDeployed").mockResolvedValue(exists); + const Ctor = CavosStellar as any as new (...args: any[]) => CavosStellar; + return new Ctor( + identity, + control.publicKey(), + status, + "stellar-testnet", + adapter, + LocalDeviceUnwrapKey.generate(), + new KeypairControlKey(control), + new Uint8Array(32), + relayer, + { appSalt: "test", backendUrl: "https://cavos.xyz", startingBalance: 50000000n }, + ); +} + +function makeRelayer() { + return { + getSource: async () => Keypair.random().publicKey(), + submit: jest.fn().mockResolvedValue("createHash"), + } as unknown as StellarRelayer; +} + +describe("CavosStellar.create", () => { + it("creates an undeployed account through the relayer and becomes ready", async () => { + const relayer = makeRelayer(); + const { keypair: control } = generateControlKey(); + const wallet = makeWallet("undeployed", relayer, control, false); + + await wallet.create(); + + expect((relayer as any).submit).toHaveBeenCalledTimes(1); + expect((relayer as any).submit).toHaveBeenCalledWith("create", expect.any(String)); + expect(wallet.status).toBe("ready"); + expect(wallet.isDeployed).toBe(true); + }); + + it("does nothing when the account is already ready", async () => { + const relayer = makeRelayer(); + const { keypair: control } = generateControlKey(); + const wallet = makeWallet("ready", relayer, control, true); + + await wallet.create(); + + expect((relayer as any).submit).not.toHaveBeenCalled(); + expect(wallet.status).toBe("ready"); + }); + + it("lets addTrustline run right after create", async () => { + const relayer = makeRelayer(); + const { keypair: control } = generateControlKey(); + const wallet = makeWallet("undeployed", relayer, control, false); + + await expect( + wallet.addTrustline({ code: "USDC", issuer: Keypair.random().publicKey() }), + ).rejects.toThrow("Call create() first"); + + await wallet.create(); + (relayer as any).submit.mockResolvedValueOnce("trustlineHash"); + const hash = await wallet.addTrustline({ code: "USDC", issuer: Keypair.random().publicKey() }); + + expect(hash).toBe("trustlineHash"); + expect((relayer as any).submit).toHaveBeenLastCalledWith("trustline", expect.any(String)); + }); +}); diff --git a/src/chains/stellar/CavosStellar.ts b/src/chains/stellar/CavosStellar.ts index 3235a13..dcede58 100644 --- a/src/chains/stellar/CavosStellar.ts +++ b/src/chains/stellar/CavosStellar.ts @@ -398,10 +398,11 @@ export class CavosStellar { * Move `amount` stroops of native XLM to `destination`, signed by the control * key. * - * **Lazy deploy**: If the account is undeployed, the first execute creates the - * account first (sponsored, 0 XLM cost), then performs the payment in a follow-up - * transaction. Native XLM cannot ride the create tx (the account needs to exist - * to send FROM it). + * **Lazy deploy**: on an undeployed account, execute first creates it, then + * sends the payment in a second transaction. A sponsored account starts with + * 0 XLM, so the payment fails with `op_underfunded` unless XLM reached it + * first; the account is created either way. Use {@link create} to create the + * account without sending anything. * * Sponsored by default (the relayer fee-bumps and pays the fee); pass * `{ sponsored: false }` to submit directly — the account pays its own (tiny) @@ -426,6 +427,17 @@ export class CavosStellar { return this.submitInner(inner, control, opts); } + /** + * Create the account on-chain without sending anything. Sponsored when an + * `appId` is set, so the account starts with 0 XLM. Resolves immediately when + * the account is already `ready`. Needed before `addTrustline` and + * `invokeContract` on a new account. + */ + async create(): Promise { + if (this.statusValue === "ready") return; + await this._createAccount(); + } + /** * Create the account first (sponsored), then perform the payment. * Called by execute() when status is "undeployed". @@ -558,7 +570,7 @@ export class CavosStellar { opts?: ExecuteOptions; }): Promise { if (this.statusValue === "undeployed") { - throw new Error("kit/stellar: invokeContract requires a deployed account. Call execute() first to create the account."); + throw new Error("kit/stellar: invokeContract requires a deployed account. Call create() first."); } const control = this.requireReadyControl(); const scArgs = (params.args ?? []).map((a) => @@ -586,7 +598,7 @@ export class CavosStellar { opts?: ExecuteOptions & { limit?: string }, ): Promise { if (this.statusValue === "undeployed") { - throw new Error("kit/stellar: addTrustline requires a deployed account. Call execute() first to create the account."); + throw new Error("kit/stellar: addTrustline requires a deployed account. Call create() first."); } const control = this.requireReadyControl(); const sponsored = resolveFeeMode(opts, 'sponsored') === 'sponsored';