Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/server-signer-sign.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
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);
});
});
31 changes: 28 additions & 3 deletions packages/wallets/src/utils/server-signers/createServerSigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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:<address>`.
*
* 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<string>;
};

/**
Expand All @@ -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
Expand All @@ -35,14 +47,27 @@ 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 {
const { secret, chain, projectId, environment } = params;
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,
};
}
Loading