본문으로 건너뛰기

서명

AppKit이 제공하는 서명 경로는 두 가지입니다. EVM 계열은 request<T>()personal_signeth_signTypedData_v4 같은 메서드를 전달하고, Solana는 전용 메서드 signSolanaTransaction()을 사용합니다. 둘 다 서명 요청을 지갑에 위임할 뿐이며, SDK가 서명 결과를 온체인에 제출하지는 않습니다.

서명 요청 정보가 텔레메트리에 포함될 수 있습니다

signSolanaTransaction()은 현재 구현에서 승인/거절 결과를 텔레메트리에 기록하고, 서명자 주소와 전체 base64 트랜잭션을 함께 보냅니다. 운영 배포에서는 해당 페이로드가 보안 검토를 통과하기 전까지 telemetry: false를 유지하세요.

사전 준비

서명은 연결된 지갑에서만 요청할 수 있습니다. 서명 메서드를 호출하기 전에 지갑 연결 또는 릴레이로 지갑 연결로 연결을 완료해야 하며, 연결 전에 호출하면 NOT_CONNECTED가 발생합니다. 인스턴스 생성과 초기화는 AppKit 설정에서 다룹니다.

서명 대상 계정은 주 연결 계정입니다. EVM 요청에서는 getAccounts()[0]에 들어 있는 0x... 주소를 사용합니다.

서명 계정 확인
const address = appKit.getAccounts()[0];

if (!address) {
throw new Error("연결된 계정을 찾을 수 없습니다.");
}

1. 메시지 서명

personal_sign은 EIP-191 메시지 서명을 요청할 때 사용합니다. params[message, address] 순서이며, 메시지는 hex 문자열로 전달합니다.

personal_sign
const address = appKit.getAccounts()[0];
if (!address) {
throw new Error("연결된 EVM 계정을 찾을 수 없습니다.");
}
const message = "0x48656c6c6f2c20536f7572636520436f6e6e656374";

const signature = await appKit.request<string>({
method: "personal_sign",
params: [message, address],
});

console.log(signature);

릴레이 연결에서도 호출 방식은 같습니다. request()가 세션 채널로 자동 라우팅합니다.

2. 구조화 데이터 서명

eth_signTypedData_v4는 EIP-712 구조화 데이터를 서명할 때 사용합니다. params[address, typedDataJson] 순서이며, 두 번째 값은 JSON.stringify()로 직렬화한 문자열이어야 합니다.

eth_signTypedData_v4
const address = appKit.getAccounts()[0];
if (!address) {
throw new Error("연결된 EVM 계정을 찾을 수 없습니다.");
}
const typedData = {
domain: {
name: "Scope Connect",
version: "1",
chainId: 11155111,
verifyingContract: "0x0000000000000000000000000000000000000000",
},
primaryType: "Mail",
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
Mail: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "contents", type: "string" },
],
},
message: {
from: address,
to: "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063",
contents: "결제 승인",
},
};

const signature = await appKit.request<string>({
method: "eth_signTypedData_v4",
params: [address, JSON.stringify(typedData)],
});

console.log(signature);

도메인, 체인 ID, 서명 검증에 사용할 컨트랙트와 메시지 필드는 URL이나 외부 응답을 그대로 사용하지 않습니다. dApp에 설정된 컨트랙트 정보와 대조하고 사용자가 확인할 수 있는 의미로 표시한 뒤 서명을 요청합니다.

3. Solana 트랜잭션 서명

Solana는 signSolanaTransaction(transactionBase64: string, opts?: { ref?: string })를 사용합니다. 서버가 만든 base64 트랜잭션을 넘기면 슬롯별 서명과 재직렬화한 트랜잭션을 반환합니다. ref는 선택 사항이며, 텔레메트리에서 서명과 이후 거래 보고를 연결할 때 사용됩니다.

transactionBase64 만들기

transactionBase64는 서버가 빌드해 직렬화한 트랜잭션의 base64 문자열입니다. AppKit은 이 값을 만들지 않습니다 — 명령어 구성, recentBlockhash 조회, fee payer 지정, 가스리스 경로의 릴레이어 partial-sign은 모두 서버 책임입니다. SDK는 받은 바이트를 역직렬화해 지갑에 서명을 요청할 뿐입니다.

base58이 아니라 base64입니다

Solana 생태계는 주소와 서명을 base58로 다루지만, 이 파라미터는 base64입니다. base58을 넘기면 역직렬화 단계에서 실패합니다. 빈 문자열이나 문자열이 아닌 값은 INVALID_CONFIG입니다.

서버는 VersionedTransaction(v0)과 legacy Transaction을 모두 보낼 수 있습니다. AppKit은 받은 바이트를 VersionedTransaction.deserialize()로 먼저 해석하는데, 이 함수가 legacy 메시지까지 함께 처리하므로 두 형식 모두 이 경로로 들어갑니다. legacy Transaction.from()으로의 되돌림은 앞 단계가 바이트를 아예 거부할 때만 쓰입니다.

server/build-solana-transaction.ts
import {
Connection,
PublicKey,
SystemProgram,
Transaction,
TransactionMessage,
VersionedTransaction,
} from "@solana/web3.js";

/** VersionedTransaction 경로 — 서명 슬롯이 비어 있어도 그대로 직렬화됩니다. */
export async function buildVersioned(
connection: Connection,
payer: PublicKey,
recipient: PublicKey,
lamports: number,
): Promise<string> {
const { blockhash } = await connection.getLatestBlockhash();
const message = new TransactionMessage({
payerKey: payer,
recentBlockhash: blockhash,
instructions: [SystemProgram.transfer({ fromPubkey: payer, toPubkey: recipient, lamports })],
}).compileToV0Message();

return Buffer.from(new VersionedTransaction(message).serialize()).toString("base64");
}

/** legacy Transaction 경로 — 아직 서명이 없으므로 requireAllSignatures 를 꺼야 합니다. */
export async function buildLegacy(
connection: Connection,
payer: PublicKey,
recipient: PublicKey,
lamports: number,
): Promise<string> {
const { blockhash } = await connection.getLatestBlockhash();
const transaction = new Transaction();
transaction.recentBlockhash = blockhash;
transaction.feePayer = payer;
transaction.add(SystemProgram.transfer({ fromPubkey: payer, toPubkey: recipient, lamports }));

const bytes = transaction.serialize({ requireAllSignatures: false, verifySignatures: false });
return Buffer.from(bytes).toString("base64");
}

legacy Transaction.serialize()는 기본값이 requireAllSignatures: true라, 사용자 서명을 받기 전 트랜잭션에는 그대로 쓰면 예외가 납니다. 릴레이어가 fee payer로 slot 0만 partial-sign한 가스리스 트랜잭션도 아직 서명이 다 차지 않은 상태이므로 동일하게 꺼야 합니다. VersionedTransaction.serialize()에는 이 옵션이 없고 미서명 상태로도 직렬화됩니다.

서명 요청

signSolanaTransaction
const telemetryRef = crypto.randomUUID();

// 서버가 빌드한 base64 트랜잭션을 받아옵니다.
const { transactionBase64 } = await fetch("/api/solana/build-transfer", {
method: "POST",
}).then((response) => response.json() as Promise<{ transactionBase64: string }>);

const result = await appKit.signSolanaTransaction(transactionBase64, {
ref: telemetryRef,
});

console.log(result.signatures);
console.log(result.signedTransaction);

데스크톱의 주입형 Phantom·Solflare 경로에서는 @solana/web3.js를 사용해 signedTransaction까지 채워 반환합니다. 모바일 경로에서는 signedTransactionnull일 수 있고, 사용자 서명 슬롯만 채워질 수 있습니다. 이 경우 signatures에서 사용자 서명 슬롯을 직접 읽습니다.

사용자 서명 슬롯을 서버와 미리 정하세요

signatures는 트랜잭션의 서명자 순서(fee payer 우선)를 그대로 따르고, 아직 서명되지 않은 슬롯은 null입니다. 릴레이어가 fee payer로 slot 0을 partial-sign해 보낸 가스리스 트랜잭션에서는 첫 번째 null이 아닌 슬롯이 릴레이어 서명이고 사용자 서명은 slot 1입니다. 사용자가 유일한 서명자일 때만 slot 0이 사용자 서명입니다.

어느 슬롯이 사용자 서명인지는 트랜잭션을 빌드한 서버와 합의한 형식을 따릅니다. 슬롯 인덱스를 서버와 함께 고정하고, 그 인덱스로 읽습니다.

src/features/wallet/read-user-signature.ts
/**
* 서버가 정한 사용자 서명 슬롯을 읽습니다.
* 릴레이어가 fee payer로 slot 0을 선점하는 가스리스 경로는 userSlot === 1 입니다.
*/
export function readUserSignature(
signatures: (string | null)[],
userSlot: number,
): string {
const signature = signatures[userSlot] ?? null;

if (!signature) {
throw new Error(`사용자 서명 슬롯 ${userSlot}이 비어 있습니다.`);
}

return signature;
}

슬롯 구성과 반환 타입은 AppKit OperationssignSolanaTransaction() 항목에서 확인합니다.

ref를 사용한다면 결제 ID나 사용자 ID를 그대로 넣지 않습니다. 임의로 생성한 비민감 상관값을 사용하고 필요한 매핑은 서버의 접근 통제된 저장소에서 관리합니다.

4. 오류 처리

기본 AppKit 경로의 서명 실패는 주로 AppError로 전달됩니다. 사용자 정의 커넥터나 플랫폼 라이브러리의 자체 오류도 전달될 수 있으므로 AppError를 먼저 처리한 뒤 나머지 오류를 상위 계층으로 전달합니다.

  • personal_sign, eth_signTypedData_v4: NOT_CONNECTED, USER_REJECTED, 지갑별 RPC 오류
  • signSolanaTransaction(): NOT_CONNECTED, UNSUPPORTED_METHOD, USER_REJECTED
  • 빈 Solana 트랜잭션은 INVALID_CONFIG
  • 데스크톱 Solana 경로에서 예상된 peer가 없으면 RPC_ERROR
  • 모바일 경로에서는 백엔드 오류, 잘못된 응답, 세션 타임아웃이 추가로 전파될 수 있습니다
서명 오류 처리
import { AppError, AppResultCode } from "@scope-connect/appkit";

try {
await appKit.signSolanaTransaction(transactionBase64);
} catch (error) {
if (error instanceof AppError) {
if (error.code === AppResultCode.USER_REJECTED) {
// 사용자가 서명을 거부했습니다.
}
if (error.code === AppResultCode.NOT_CONNECTED) {
// 먼저 연결해야 합니다.
}
}
throw error;
}

다음 단계