From 2224dd816c6220f7a032086a112af4598ffa2708 Mon Sep 17 00:00:00 2001 From: Alberto Elias Date: Wed, 4 Nov 2026 20:57:17 -0300 Subject: [PATCH 1/3] feat(wallets): createServerSigner returns a sign(message) function A server signer made with createServerSigner returned only its address and key bytes, so a backend that had to sign an approval itself (for example a quorum member's message, which the SDK does not sign yet) rebuilt each chain's signing from keyBytes. sign(message) reuses the SDK's own EVM, Solana and Stellar server signers, so the signature matches what the SDK posts when the same server signer approves on its own. keyBytes stays intact: the adapter gets a copy, since it wipes its input. --- .changeset/server-signer-sign.md | 5 ++ .../server-signers/createServerSigner.test.ts | 66 +++++++++++++++++++ .../server-signers/createServerSigner.ts | 31 ++++++++- 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 .changeset/server-signer-sign.md create mode 100644 packages/wallets/src/utils/server-signers/createServerSigner.test.ts diff --git a/.changeset/server-signer-sign.md b/.changeset/server-signer-sign.md new file mode 100644 index 000000000..6dfc0ee94 --- /dev/null +++ b/.changeset/server-signer-sign.md @@ -0,0 +1,5 @@ +--- +"@crossmint/wallets-sdk": minor +--- + +`createServerSigner` now returns a `sign(message)` function. It signs a message the API asks the server signer to approve (a transaction or signature approval, or a quorum member's approval message) exactly as the SDK's own server signer does on that chain: raw EIP-191 over 0x-hex on EVM, ed25519 over base58 on Solana, ed25519 over base64 on Stellar. Callers no longer need to rebuild per-chain signing from `keyBytes`. diff --git a/packages/wallets/src/utils/server-signers/createServerSigner.test.ts b/packages/wallets/src/utils/server-signers/createServerSigner.test.ts new file mode 100644 index 000000000..ae30f8a9d --- /dev/null +++ b/packages/wallets/src/utils/server-signers/createServerSigner.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import base58 from "bs58"; +import { ed25519 } from "@noble/curves/ed25519"; +import { recoverMessageAddress } from "viem"; + +import { encodeStellarPublicKey } from "../stellar"; +import { createServerSigner } from "./createServerSigner"; + +const params = { secret: "a".repeat(64), projectId: "project-123", environment: "staging" }; + +describe("createServerSigner().sign", () => { + it("EVM: signs the raw hex message so it recovers to the signer's address", async () => { + const signer = createServerSigner({ ...params, chain: "base-sepolia" }); + const message = "0x7b2a0c1d9e"; + + const signature = await signer.sign(message); + + expect(signature).toMatch(/^0x[0-9a-f]{130}$/); + await expect( + recoverMessageAddress({ message: { raw: message }, signature: signature as `0x${string}` }) + ).resolves.toBe(signer.address); + }); + + it("EVM: rejects a message that is not 0x-prefixed hex", async () => { + const signer = createServerSigner({ ...params, chain: "base-sepolia" }); + + await expect(signer.sign("7b2a")).rejects.toThrow("0x-prefixed hex"); + }); + + it("Solana: signs the base58-decoded message with the address's ed25519 key, base58 out", async () => { + const signer = createServerSigner({ ...params, chain: "solana" }); + const messageBytes = new Uint8Array([1, 2, 3, 250, 251, 252]); + + const signature = await signer.sign(base58.encode(messageBytes)); + + expect(ed25519.verify(base58.decode(signature), messageBytes, base58.decode(signer.address))).toBe(true); + }); + + it("Stellar: signs the base64-decoded message with the address's ed25519 key, base64 out", async () => { + const signer = createServerSigner({ ...params, chain: "stellar" }); + // A quorum member's Stellar message is the 32-byte auth-entry preimage hash, base64-encoded. + const messageBytes = new Uint8Array(32).map((_, i) => i * 7); + const publicKey = ed25519.getPublicKey(signer.keyBytes); + expect(encodeStellarPublicKey(publicKey)).toBe(signer.address); + + const signature = await signer.sign(Buffer.from(messageBytes).toString("base64")); + + expect(ed25519.verify(Buffer.from(signature, "base64"), messageBytes, publicKey)).toBe(true); + }); + + it("Stellar: rejects a message that is not base64", async () => { + const signer = createServerSigner({ ...params, chain: "stellar" }); + + await expect(signer.sign("not base64!")).rejects.toThrow("base64"); + }); + + it("leaves keyBytes intact after signing", async () => { + const signer = createServerSigner({ ...params, chain: "solana" }); + const before = signer.keyBytes.slice(); + + await signer.sign(base58.encode(new Uint8Array([9]))); + + expect(signer.keyBytes).toEqual(before); + expect(before.some((b) => b !== 0)).toBe(true); + }); +}); diff --git a/packages/wallets/src/utils/server-signers/createServerSigner.ts b/packages/wallets/src/utils/server-signers/createServerSigner.ts index da73a65ee..caffefe49 100644 --- a/packages/wallets/src/utils/server-signers/createServerSigner.ts +++ b/packages/wallets/src/utils/server-signers/createServerSigner.ts @@ -2,6 +2,7 @@ import type { Chain } from "../../chains/chains"; import { deriveKeyBytes } from "../server-key-derivation"; import { deriveServerSignerAddress } from "../../signers/server/helpers/derive-server-signer"; import { getChainType } from "../../signers/server/helpers/get-chain-type"; +import { assembleServerSigner } from "../../signers/server/assemble-server-signer"; export type CreateServerSignerParams = { secret: string; @@ -14,6 +15,17 @@ export type ServerSigner = { type: "server"; address: string; keyBytes: Uint8Array; + /** + * Signs a message the API asks this signer to approve, such as `approvals.pending[].message` on a transaction + * or signature, or a quorum member's `quorumApprovals.pending[].message`. Signs exactly as the SDK does when + * this server signer approves on its own, and returns the signature to post under `server:
`. + * + * The message is in the encoding the API returns for the chain: + * - EVM: 0x-prefixed hex, signed as a raw EIP-191 personal message; returns a 0x-prefixed signature. + * - Solana: base58, signed with ed25519; returns a base58 signature. + * - Stellar: base64, signed with ed25519; returns a base64 signature. + */ + sign: (message: string) => Promise; }; /** @@ -25,7 +37,7 @@ export type ServerSigner = { * @param params.chain - Target blockchain chain * @param params.projectId - Project ID from the API key * @param params.environment - Environment from the API key (staging, production) - * @returns A server signer containing the type, derived address, and key bytes. + * @returns A server signer containing the type, derived address, key bytes, and a `sign` function. * * @example * ```ts @@ -35,7 +47,8 @@ export type ServerSigner = { * projectId: "project-id", * environment: "staging", * }); - * // signer = { type: "server", address: "0x...", keyBytes: Uint8Array } + * // signer = { type: "server", address: "0x...", keyBytes: Uint8Array, sign } + * const signature = await signer.sign(pending.message); * ``` */ export function createServerSigner(params: CreateServerSignerParams): ServerSigner { @@ -43,6 +56,18 @@ export function createServerSigner(params: CreateServerSignerParams): ServerSign const chainType = getChainType(chain); const keyBytes = deriveKeyBytes(secret, projectId, environment, chainType); const address = deriveServerSignerAddress(keyBytes, chain); + // The adapter wipes the bytes it is given, so hand it a copy and leave `keyBytes` intact for the caller. + const adapter = assembleServerSigner(chain, { + type: "server", + derivedKeyBytes: keyBytes.slice(), + locator: `server:${address}`, + address, + }); - return { type: "server", address, keyBytes }; + return { + type: "server", + address, + keyBytes, + sign: async (message) => (await adapter.signMessage(message)).signature, + }; } From 3049988ea68341875801fcfbc059231c55c25c0e Mon Sep 17 00:00:00 2001 From: Alberto Elias Date: Wed, 4 Nov 2026 23:29:51 -0300 Subject: [PATCH 2/3] test(wallets): cover a non-base58 Solana message and repeated signing in createServerSigner --- .../server-signers/createServerSigner.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/wallets/src/utils/server-signers/createServerSigner.test.ts b/packages/wallets/src/utils/server-signers/createServerSigner.test.ts index ae30f8a9d..90739a751 100644 --- a/packages/wallets/src/utils/server-signers/createServerSigner.test.ts +++ b/packages/wallets/src/utils/server-signers/createServerSigner.test.ts @@ -36,6 +36,12 @@ describe("createServerSigner().sign", () => { expect(ed25519.verify(base58.decode(signature), messageBytes, base58.decode(signer.address))).toBe(true); }); + it("Solana: rejects a message that is not base58", async () => { + const signer = createServerSigner({ ...params, chain: "solana" }); + + await expect(signer.sign("0OIl")).rejects.toThrow("base58"); + }); + it("Stellar: signs the base64-decoded message with the address's ed25519 key, base64 out", async () => { const signer = createServerSigner({ ...params, chain: "stellar" }); // A quorum member's Stellar message is the 32-byte auth-entry preimage hash, base64-encoded. @@ -54,6 +60,15 @@ describe("createServerSigner().sign", () => { await expect(signer.sign("not base64!")).rejects.toThrow("base64"); }); + it("signs again with the same signer after a first signature", async () => { + const signer = createServerSigner({ ...params, chain: "base-sepolia" }); + const message = "0x01"; + + const first = await signer.sign(message); + + await expect(signer.sign(message)).resolves.toBe(first); + }); + it("leaves keyBytes intact after signing", async () => { const signer = createServerSigner({ ...params, chain: "solana" }); const before = signer.keyBytes.slice(); From 7206f94eb9b1cafbc328845f42c1ac298ffa6450 Mon Sep 17 00:00:00 2001 From: Alberto Elias Date: Wed, 4 Nov 2026 23:40:48 -0300 Subject: [PATCH 3/3] test(wallets): kebab-case the createServerSigner test file --- .../{createServerSigner.test.ts => create-server-signer.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/wallets/src/utils/server-signers/{createServerSigner.test.ts => create-server-signer.test.ts} (100%) diff --git a/packages/wallets/src/utils/server-signers/createServerSigner.test.ts b/packages/wallets/src/utils/server-signers/create-server-signer.test.ts similarity index 100% rename from packages/wallets/src/utils/server-signers/createServerSigner.test.ts rename to packages/wallets/src/utils/server-signers/create-server-signer.test.ts