From 972b8b9b42982ad646bd3b503de21cce67a1875f Mon Sep 17 00:00:00 2001 From: Lawal Abubakar Babatunde Date: Sat, 12 Sep 2026 14:37:39 +0100 Subject: [PATCH 1/3] feat(stellar): add gasless approval sponsorship intent --- CHANGELOG.md | 7 +++++++ package.json | 2 +- src/stellar/sponsorship-client.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d5135..578ab7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to `@fundable/sdk` are documented here. +## 0.2.2 + +### Added + +- a typed Stellar sponsorship intent for exact, backend-validated token + approvals used by gasless Lockup funding. + ## 0.2.1 ### Fixed diff --git a/package.json b/package.json index f34f28c..47be5f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fundable/sdk", - "version": "0.2.1", + "version": "0.2.2", "private": false, "description": "Multichain TypeScript SDK for Fundable Protocol; Stellar adapter included", "license": "MIT", diff --git a/src/stellar/sponsorship-client.ts b/src/stellar/sponsorship-client.ts index a471b9e..b326f0b 100644 --- a/src/stellar/sponsorship-client.ts +++ b/src/stellar/sponsorship-client.ts @@ -48,6 +48,14 @@ interface ExistingStreamIntent { } export type StellarSponsorIntent = + | { + operation: "approve"; + owner: string; + token: string; + spender: string; + amount: string; + expiration_ledger: number; + } | { operation: "create"; stream_kind: "lockup"; From aae9f8eabb137d3063a8ad077133e9944589c933 Mon Sep 17 00:00:00 2001 From: Lawal Abubakar Babatunde Date: Sat, 12 Sep 2026 14:53:04 +0100 Subject: [PATCH 2/3] fix(stellar): normalize unsigned sponsorship auth entries --- src/stellar/authorization-entry.test.ts | 42 +++++++++++++++++++++++++ src/stellar/authorization-entry.ts | 23 ++++++++++++++ src/stellar/client.ts | 12 ++++--- src/stellar/index.ts | 1 + 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 src/stellar/authorization-entry.test.ts create mode 100644 src/stellar/authorization-entry.ts diff --git a/src/stellar/authorization-entry.test.ts b/src/stellar/authorization-entry.test.ts new file mode 100644 index 0000000..0d9b0df --- /dev/null +++ b/src/stellar/authorization-entry.test.ts @@ -0,0 +1,42 @@ +import { Address, Contract, Keypair, nativeToScVal, xdr } from "@stellar/stellar-sdk"; +import { describe, expect, it } from "vitest"; +import { normalizeUnsignedAuthorizationEntry } from "./authorization-entry.js"; + +describe("normalizeUnsignedAuthorizationEntry", () => { + it("replaces an empty signature vector with the unsigned void marker", () => { + const signer = Keypair.random().publicKey(); + const contract = new Contract( + "CDJM3SROZG3TY3URXSFH7J5GEIVFHZZKWX5DVJISED6YBIONA76WBU7D", + ); + const input = new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( + new xdr.SorobanAddressCredentials({ + address: Address.fromString(signer).toScAddress(), + nonce: xdr.Int64.fromString("1"), + signatureExpirationLedger: 2_000, + signature: xdr.ScVal.scvVec([]), + }), + ), + rootInvocation: new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: contract.address().toScAddress(), + functionName: "forward", + args: [nativeToScVal(signer, { type: "address" })], + }), + ), + subInvocations: [], + }), + }).toXDR("base64"); + + const normalized = xdr.SorobanAuthorizationEntry.fromXDR( + normalizeUnsignedAuthorizationEntry(input), + "base64", + ); + + expect(normalized.credentials().address().signature().switch().name).toBe( + "scvVoid", + ); + }); +}); diff --git a/src/stellar/authorization-entry.ts b/src/stellar/authorization-entry.ts new file mode 100644 index 0000000..1c7d563 --- /dev/null +++ b/src/stellar/authorization-entry.ts @@ -0,0 +1,23 @@ +import { xdr } from "@stellar/stellar-sdk"; + +/** Convert relayer empty-vector signature placeholders into wallet-signable entries. */ +export function normalizeUnsignedAuthorizationEntry(authEntry: string): string { + let entry: xdr.SorobanAuthorizationEntry; + try { + entry = xdr.SorobanAuthorizationEntry.fromXDR(authEntry, "base64"); + } catch { + return authEntry; + } + + const credentials = entry.credentials(); + if (credentials.switch().name !== "sorobanCredentialsAddress") { + return entry.toXDR("base64"); + } + + const signature = credentials.address().signature(); + if (signature.switch().name === "scvVec" && signature.vec()?.length === 0) { + credentials.address().signature(xdr.ScVal.scvVoid()); + } + + return entry.toXDR("base64"); +} diff --git a/src/stellar/client.ts b/src/stellar/client.ts index e55cde2..207a7dd 100644 --- a/src/stellar/client.ts +++ b/src/stellar/client.ts @@ -1,4 +1,5 @@ import { FUNDABLE_ERROR_CODES, FundableError } from "../core/index.js"; +import { normalizeUnsignedAuthorizationEntry } from "./authorization-entry.js"; import { StellarFlowClient } from "./flow-client.js"; import { StellarLockupClient } from "./lockup-client.js"; import { StellarPaymasterClient } from "./paymaster-client.js"; @@ -86,10 +87,13 @@ export class StellarFundableClient { chain: "stellar", }); } - const result = await this.config.signAuthEntry(build.userAuthEntry, { - networkPassphrase: this.config.networkPassphrase, - address: this.config.publicKey, - }); + const result = await this.config.signAuthEntry( + normalizeUnsignedAuthorizationEntry(build.userAuthEntry), + { + networkPassphrase: this.config.networkPassphrase, + address: this.config.publicKey, + }, + ); if (result.error || !result.signedAuthEntry) { throw new FundableError({ code: FUNDABLE_ERROR_CODES.TRANSACTION_FAILED, diff --git a/src/stellar/index.ts b/src/stellar/index.ts index 595a129..69738f8 100644 --- a/src/stellar/index.ts +++ b/src/stellar/index.ts @@ -6,3 +6,4 @@ export * from "./router-client.js"; export * from "./stream-nft-client.js"; export * from "./sponsorship-client.js"; export * from "./types.js"; +export * from "./authorization-entry.js"; From 8e1565477a50db954045bc375bca7c17743435af Mon Sep 17 00:00:00 2001 From: Lawal Abubakar Babatunde Date: Sun, 13 Sep 2026 14:53:37 +0100 Subject: [PATCH 3/3] fix(stellar): sign sponsorship authorization preimages --- CHANGELOG.md | 5 +++ src/stellar/authorization-entry.test.ts | 42 ---------------------- src/stellar/authorization-entry.ts | 23 ------------- src/stellar/client.test.ts | 46 ++++++++++++++++++++----- src/stellar/client.ts | 44 +++++++++++++++-------- src/stellar/index.ts | 1 - 6 files changed, 72 insertions(+), 89 deletions(-) delete mode 100644 src/stellar/authorization-entry.test.ts delete mode 100644 src/stellar/authorization-entry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 578ab7b..7a5325a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ All notable changes to `@fundable/sdk` are documented here. - a typed Stellar sponsorship intent for exact, backend-validated token approvals used by gasless Lockup funding. +### Fixed + +- sign the Soroban authorization preimage through the wallet and insert the + returned signature into the relayer authorization entry before submission. + ## 0.2.1 ### Fixed diff --git a/src/stellar/authorization-entry.test.ts b/src/stellar/authorization-entry.test.ts deleted file mode 100644 index 0d9b0df..0000000 --- a/src/stellar/authorization-entry.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Address, Contract, Keypair, nativeToScVal, xdr } from "@stellar/stellar-sdk"; -import { describe, expect, it } from "vitest"; -import { normalizeUnsignedAuthorizationEntry } from "./authorization-entry.js"; - -describe("normalizeUnsignedAuthorizationEntry", () => { - it("replaces an empty signature vector with the unsigned void marker", () => { - const signer = Keypair.random().publicKey(); - const contract = new Contract( - "CDJM3SROZG3TY3URXSFH7J5GEIVFHZZKWX5DVJISED6YBIONA76WBU7D", - ); - const input = new xdr.SorobanAuthorizationEntry({ - credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( - new xdr.SorobanAddressCredentials({ - address: Address.fromString(signer).toScAddress(), - nonce: xdr.Int64.fromString("1"), - signatureExpirationLedger: 2_000, - signature: xdr.ScVal.scvVec([]), - }), - ), - rootInvocation: new xdr.SorobanAuthorizedInvocation({ - function: - xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( - new xdr.InvokeContractArgs({ - contractAddress: contract.address().toScAddress(), - functionName: "forward", - args: [nativeToScVal(signer, { type: "address" })], - }), - ), - subInvocations: [], - }), - }).toXDR("base64"); - - const normalized = xdr.SorobanAuthorizationEntry.fromXDR( - normalizeUnsignedAuthorizationEntry(input), - "base64", - ); - - expect(normalized.credentials().address().signature().switch().name).toBe( - "scvVoid", - ); - }); -}); diff --git a/src/stellar/authorization-entry.ts b/src/stellar/authorization-entry.ts deleted file mode 100644 index 1c7d563..0000000 --- a/src/stellar/authorization-entry.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { xdr } from "@stellar/stellar-sdk"; - -/** Convert relayer empty-vector signature placeholders into wallet-signable entries. */ -export function normalizeUnsignedAuthorizationEntry(authEntry: string): string { - let entry: xdr.SorobanAuthorizationEntry; - try { - entry = xdr.SorobanAuthorizationEntry.fromXDR(authEntry, "base64"); - } catch { - return authEntry; - } - - const credentials = entry.credentials(); - if (credentials.switch().name !== "sorobanCredentialsAddress") { - return entry.toXDR("base64"); - } - - const signature = credentials.address().signature(); - if (signature.switch().name === "scvVec" && signature.vec()?.length === 0) { - credentials.address().signature(xdr.ScVal.scvVoid()); - } - - return entry.toXDR("base64"); -} diff --git a/src/stellar/client.test.ts b/src/stellar/client.test.ts index f15e217..a3a8b80 100644 --- a/src/stellar/client.test.ts +++ b/src/stellar/client.test.ts @@ -1,4 +1,4 @@ -import { StrKey } from "@stellar/stellar-sdk"; +import { Address, Contract, hash, Keypair, nativeToScVal, StrKey, xdr } from "@stellar/stellar-sdk"; import { describe, expect, it, vi } from "vitest"; import { FundableError } from "../core/index.js"; import { createFundableClient } from "../client.js"; @@ -7,6 +7,31 @@ function contractId(): string { return StrKey.encodeContract(new Uint8Array(32)); } +function unsignedAuthorizationEntry(): { entryXdr: string; signer: Keypair } { + const signer = Keypair.random(); + const entryXdr = new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( + new xdr.SorobanAddressCredentials({ + address: Address.fromString(signer.publicKey()).toScAddress(), + nonce: xdr.Int64.fromString("1"), + signatureExpirationLedger: 2_000, + signature: xdr.ScVal.scvVec([]), + }), + ), + rootInvocation: new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: new Contract(contractId()).address().toScAddress(), + functionName: "forward", + args: [nativeToScVal(signer.publicKey(), { type: "address" })], + }), + ), + subInvocations: [], + }), + }).toXDR("base64"); + return { entryXdr, signer }; +} + describe("createFundableClient", () => { it("creates the Stellar adapter with Flow capabilities", () => { const client = createFundableClient({ @@ -55,8 +80,11 @@ describe("createFundableClient", () => { }); it("signs a current sponsorship build with the configured wallet", async () => { - const signAuthEntry = vi.fn(async () => ({ - signedAuthEntry: "signed-entry", + const authorization = unsignedAuthorizationEntry(); + const signAuthEntry = vi.fn(async (preimageXdr: string) => ({ + signedAuthEntry: authorization.signer + .sign(hash(Buffer.from(preimageXdr, "base64"))) + .toString("base64"), signerAddress: "GACCOUNT", })); const client = createFundableClient({ @@ -73,10 +101,9 @@ describe("createFundableClient", () => { }, }); - await expect( - client.signSponsorshipAuthorization({ + const result = await client.signSponsorshipAuthorization({ transactionXdr: "built-xdr", - userAuthEntry: "auth-entry", + userAuthEntry: authorization.entryXdr, feeToken: "CFEE", networkFeeStroops: "100", estimatedFee: "123", @@ -84,9 +111,10 @@ describe("createFundableClient", () => { maximumFee: "130", maximumFeeUi: "0.0000130", validUntil: "2030-01-01T00:00:00.000Z", - }), - ).resolves.toBe("signed-entry"); - expect(signAuthEntry).toHaveBeenCalledWith("auth-entry", { + }); + const signed = xdr.SorobanAuthorizationEntry.fromXDR(result, "base64"); + expect(signed.credentials().address().signature().switch().name).toBe("scvVec"); + expect(signAuthEntry).toHaveBeenCalledWith(expect.any(String), { networkPassphrase: "Test SDF Network ; September 2015", address: "GACCOUNT", }); diff --git a/src/stellar/client.ts b/src/stellar/client.ts index 207a7dd..3c22e3e 100644 --- a/src/stellar/client.ts +++ b/src/stellar/client.ts @@ -1,5 +1,5 @@ import { FUNDABLE_ERROR_CODES, FundableError } from "../core/index.js"; -import { normalizeUnsignedAuthorizationEntry } from "./authorization-entry.js"; +import { authorizeEntry, xdr } from "@stellar/stellar-sdk"; import { StellarFlowClient } from "./flow-client.js"; import { StellarLockupClient } from "./lockup-client.js"; import { StellarPaymasterClient } from "./paymaster-client.js"; @@ -10,6 +10,11 @@ import type { StellarSponsorBuild } from "./sponsorship-client.js"; import type { StellarFundableClientConfig } from "./types.js"; import { assertContractId } from "./validation.js"; +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + export class StellarFundableClient { readonly chain = "stellar" as const; readonly flows: StellarFlowClient; @@ -87,20 +92,31 @@ export class StellarFundableClient { chain: "stellar", }); } - const result = await this.config.signAuthEntry( - normalizeUnsignedAuthorizationEntry(build.userAuthEntry), - { - networkPassphrase: this.config.networkPassphrase, - address: this.config.publicKey, + const entry = xdr.SorobanAuthorizationEntry.fromXDR( + build.userAuthEntry, + "base64", + ); + const expirationLedger = entry.credentials().address().signatureExpirationLedger(); + const signedEntry = await authorizeEntry( + entry, + async (preimage) => { + const result = await this.config.signAuthEntry!(preimage.toXDR("base64"), { + networkPassphrase: this.config.networkPassphrase, + address: this.config.publicKey, + }); + if (result.error || !result.signedAuthEntry) { + throw new FundableError({ + code: FUNDABLE_ERROR_CODES.TRANSACTION_FAILED, + message: + result.error?.message ?? "The wallet did not sign the authorization entry.", + chain: "stellar", + }); + } + return base64ToBytes(result.signedAuthEntry); }, + expirationLedger, + this.config.networkPassphrase, ); - if (result.error || !result.signedAuthEntry) { - throw new FundableError({ - code: FUNDABLE_ERROR_CODES.TRANSACTION_FAILED, - message: result.error?.message ?? "The wallet did not sign the authorization entry.", - chain: "stellar", - }); - } - return result.signedAuthEntry; + return signedEntry.toXDR("base64"); } } diff --git a/src/stellar/index.ts b/src/stellar/index.ts index 69738f8..595a129 100644 --- a/src/stellar/index.ts +++ b/src/stellar/index.ts @@ -6,4 +6,3 @@ export * from "./router-client.js"; export * from "./stream-nft-client.js"; export * from "./sponsorship-client.js"; export * from "./types.js"; -export * from "./authorization-entry.js";