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: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
21 changes: 15 additions & 6 deletions skills/cavos-stellar/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand All @@ -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.

Expand Down
79 changes: 79 additions & 0 deletions src/chains/stellar/CavosStellar.create.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
24 changes: 18 additions & 6 deletions src/chains/stellar/CavosStellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<void> {
if (this.statusValue === "ready") return;
await this._createAccount();
}

/**
* Create the account first (sponsored), then perform the payment.
* Called by execute() when status is "undeployed".
Expand Down Expand Up @@ -558,7 +570,7 @@ export class CavosStellar {
opts?: ExecuteOptions;
}): Promise<string> {
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) =>
Expand Down Expand Up @@ -586,7 +598,7 @@ export class CavosStellar {
opts?: ExecuteOptions & { limit?: string },
): Promise<string> {
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';
Expand Down
Loading