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';