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/create-server-signer.test.ts b/packages/wallets/src/utils/server-signers/create-server-signer.test.ts new file mode 100644 index 000000000..90739a751 --- /dev/null +++ b/packages/wallets/src/utils/server-signers/create-server-signer.test.ts @@ -0,0 +1,81 @@ +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("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. + 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("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(); + + 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, + }; }