diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d5135..7a5325a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 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. + +### 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/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/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 e55cde2..3c22e3e 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 { authorizeEntry, xdr } from "@stellar/stellar-sdk"; import { StellarFlowClient } from "./flow-client.js"; import { StellarLockupClient } from "./lockup-client.js"; import { StellarPaymasterClient } from "./paymaster-client.js"; @@ -9,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; @@ -86,17 +92,31 @@ export class StellarFundableClient { chain: "stellar", }); } - const result = await this.config.signAuthEntry(build.userAuthEntry, { - 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 result.signedAuthEntry; + 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, + ); + return signedEntry.toXDR("base64"); } } 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";