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