diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 89b43a3c6..3edcd2653 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `signProofOfOwnership` client request for silent proof-of-ownership signing (SEP-0053) ([#186](https://github.com/MetaMask/internal-snaps/pull/186)) - Add `TrustlineExceedLimitException` for send simulation when a payment would exceed the destination trustline limit (previously a generic `TransactionValidationException`) ([#185](https://github.com/MetaMask/internal-snaps/pull/185)) ### Changed diff --git a/packages/stellar-wallet-snap/docs/use-cases/README.md b/packages/stellar-wallet-snap/docs/use-cases/README.md index ad094cda4..4e553e97a 100644 --- a/packages/stellar-wallet-snap/docs/use-cases/README.md +++ b/packages/stellar-wallet-snap/docs/use-cases/README.md @@ -12,6 +12,7 @@ High-level flows for the Stellar Wallet Snap. Each doc focuses on **handlers**, | Quote swap / bridge fee | `computeFee` | [computeFee.md](./client-request/computeFee.md) | | Sign & submit swap / bridge | `signAndSendTransaction` | [signAndSendTransaction.md](./client-request/signAndSendTransaction.md) | | Change trustline (opt-in / opt-out) | `changeTrustOpt` | [changeTrustOpt.md](./client-request/changeTrustOpt.md) | +| Silent proof-of-ownership signing | `signProofOfOwnership` | [signProofOfOwnership.md](./client-request/signProofOfOwnership.md) | ## Cronjob (`onCronjob`) diff --git a/packages/stellar-wallet-snap/docs/use-cases/client-request/signProofOfOwnership.md b/packages/stellar-wallet-snap/docs/use-cases/client-request/signProofOfOwnership.md new file mode 100644 index 000000000..38af66d53 --- /dev/null +++ b/packages/stellar-wallet-snap/docs/use-cases/client-request/signProofOfOwnership.md @@ -0,0 +1,63 @@ +# Use case: `signProofOfOwnership` + +Silently signs a proof-of-ownership message so `@metamask/profile-metrics-controller` can prove the user controls a Stellar address. + +| | | +| ---------- | --------------------------------------------------------------------------------------------------------------- | +| **Entry** | `onClientRequest` → `ClientRequestHandler` → `SignProofOfOwnershipHandler` | +| **Method** | `signProofOfOwnership` (`ClientRequestMethod.SignProofOfOwnership`) | +| **Source** | [`handlers/clientRequest/signProofOfOwnership.ts`](../../../src/handlers/clientRequest/signProofOfOwnership.ts) | + +This is a **silent sign** — there is no confirmation dialog. That is intentional: the MetaMask client needs an ownership proof without interrupting the user. The method is scoped so it cannot be used as a general sign-message bypass: + +1. SIP-31 `onClientRequest` is only callable by the MetaMask client. +2. The plaintext must be `metamask:proof-of-ownership:{nonce}:{address}`, and the embedded address must match the signing account. +3. Signing uses [SEP-0053](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md) (`Wallet.signMessage`). + +## Request / response (shape) + +**Request params** + +- `accountId` — keyring account UUID +- `message` — plaintext `metamask:proof-of-ownership:{nonce}:{address}` (nonce may contain colons; the address is the last `:`-separated field) +- `nonce`, `address` — coerced from `message` internally (clients do not send these) + +**Response** + +- `{ signature }` — standard base64 of the 64-byte ed25519 signature (SEP-0053) + +## Participants + +| Component | Path | Role in this flow | +| ----------------------------- | ------------------------ | ---------------------------------------------------- | +| `ClientRequestHandler` | `handlers/clientRequest` | Routes `signProofOfOwnership` to the handler | +| `SignProofOfOwnershipHandler` | `handlers/clientRequest` | Validates message, resolves wallet, signs | +| `AccountResolver` | `handlers/` | Loads keyring account + wallet (no on-chain account) | +| `Wallet` | `services/wallet` | SEP-0053 `signMessage` | + +## Step-by-step + +1. **Route** — `onClientRequest` dispatches to `SignProofOfOwnershipHandler`. +2. **Validate** — Request must match `SignProofOfOwnershipJsonRpcRequestStruct` (prefix, nonce, Stellar address). `nonce` and `address` are coerced from `message`. +3. **Resolve** — `BaseClientRequestHandler` loads keyring account and wallet only (`RESOLVE_ACCOUNT_KEYRING_AND_WALLET`). The destination account does not need to be activated on-chain. +4. **Bind** — The address in the message must equal the signing account address. +5. **Sign** — `Wallet.signMessage(message)` (SEP-0053, base64). + +## Sequence (happy path) + +```mermaid +sequenceDiagram + participant Client + participant Handler as SignProofOfOwnershipHandler + participant Resolver as AccountResolver + participant Wallet + + Client->>Handler: signProofOfOwnership { accountId, message } + Note over Handler: validate coerces nonce + address from message + Handler->>Resolver: resolve keyring account + wallet + Resolver-->>Handler: account, wallet + Handler->>Handler: message address == account.address + Handler->>Wallet: signMessage (SEP-0053, base64) + Wallet-->>Handler: signature + Handler-->>Client: { signature } +``` diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json index bb45847d4..4ceedbd0a 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "SszhtaffKOI/qFUP4XYJU0wGVFGwz/dQAL7pZEONfyI=", + "shasum": "I2mbgubtMTDqkgjglt3o7ji1GuNVFYlZ0L0aZNK4Jdw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index 3d13f9d4f..68aa033e5 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -15,6 +15,7 @@ import { ConfirmSendHandler } from './handlers/clientRequest/confirmSend'; import { OnAddressInputHandler } from './handlers/clientRequest/onAddressInput'; import { OnAmountInputHandler } from './handlers/clientRequest/onAmountInput'; import { SignAndSendTransactionHandler } from './handlers/clientRequest/signAndSendTransaction'; +import { SignProofOfOwnershipHandler } from './handlers/clientRequest/signProofOfOwnership'; import type { ICronjobRequestHandler } from './handlers/cronjob/api'; import { BackgroundEventMethod } from './handlers/cronjob/api'; import { @@ -291,6 +292,11 @@ const computeFeeHandler = new ComputeFeeHandler({ transactionService, }); +const signProofOfOwnershipHandler = new SignProofOfOwnershipHandler({ + logger, + accountResolver, +}); + const clientRequestMethodHandlers: Record< ClientRequestMethod, IClientRequestHandler @@ -301,6 +307,7 @@ const clientRequestMethodHandlers: Record< [ClientRequestMethod.ConfirmSend]: confirmSendHandler, [ClientRequestMethod.SignAndSendTransaction]: signAndSendTransactionHandler, [ClientRequestMethod.ComputeFee]: computeFeeHandler, + [ClientRequestMethod.SignProofOfOwnership]: signProofOfOwnershipHandler, }; const clientRequestHandler = new ClientRequestHandler({ diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts index 34fbf1ef9..fbf4e9c04 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts @@ -21,6 +21,8 @@ import { ConfirmSendJsonRpcResponseStruct, SignAndSendTransactionJsonRpcRequestStruct, SignAndSendTransactionJsonRpcResponseStruct, + SignProofOfOwnershipJsonRpcRequestStruct, + SignProofOfOwnershipJsonRpcResponseStruct, } from './api'; const accountId = '11111111-1111-4111-8111-111111111111'; @@ -918,3 +920,147 @@ describe('ConfirmSendJsonRpcResponseStruct', () => { ); }); }); + +describe('SignProofOfOwnershipJsonRpcRequestStruct', () => { + const nonce = 'a1b2c3d4e5f6789012345678'; + + it.each([ + { + message: `metamask:proof-of-ownership:${nonce}:${stellarAddress}`, + nonce, + address: stellarAddress, + }, + { + message: `metamask:proof-of-ownership:abc-DEF_123:${stellarAddress}`, + nonce: 'abc-DEF_123', + address: stellarAddress, + }, + { + message: `metamask:proof-of-ownership:ns:abc:123:${stellarAddress}`, + nonce: 'ns:abc:123', + address: stellarAddress, + }, + ])( + 'accepts a valid signProofOfOwnership request: "$message"', + ({ message, nonce: expectedNonce, address }) => { + const result = create( + { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message }, + }, + SignProofOfOwnershipJsonRpcRequestStruct, + ); + + expect(result.params).toStrictEqual({ + accountId, + message, + nonce: expectedNonce, + address, + }); + }, + ); + + it.each([ + { + method: ClientRequestMethod.ConfirmSend, + params: { + accountId, + message: `metamask:proof-of-ownership:${nonce}:${stellarAddress}`, + }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message: `rewards,${stellarAddress},123` }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: `metamask:proof:${nonce}:${stellarAddress}`, + }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: `Metamask:proof-of-ownership:${nonce}:${stellarAddress}`, + }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message: `${nonce}:${stellarAddress}` }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message: '' }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message: `metamask:proof-of-ownership:${nonce}` }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: `metamask:proof-of-ownership::${stellarAddress}`, + }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message: `metamask:proof-of-ownership:${nonce}:` }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: `metamask:proof-of-ownership:${nonce}:not-a-stellar-address`, + }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: `metamask:proof-of-ownership:${nonce}:0x1234567890abcdef1234567890abcdef12345678`, + }, + }, + ])( + 'rejects an invalid signProofOfOwnership request', + ({ method, params }) => { + expect(() => + assert( + { jsonrpc: '2.0', id: 1, method, params }, + SignProofOfOwnershipJsonRpcRequestStruct, + ), + ).toThrow(StructError); + }, + ); +}); + +describe('SignProofOfOwnershipJsonRpcResponseStruct', () => { + it('accepts a standard base64 signature', () => { + expect(() => + assert( + { + signature: + 'fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==', + }, + SignProofOfOwnershipJsonRpcResponseStruct, + ), + ).not.toThrow(); + }); + + it.each([{ signature: 'not!!!valid-base64' }, { signature: '' }, {}])( + 'rejects an invalid signProofOfOwnership response', + (response) => { + expect(() => + assert(response, SignProofOfOwnershipJsonRpcResponseStruct), + ).toThrow(StructError); + }, + ); +}); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index fd1bec48a..1907f9e46 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -18,7 +18,11 @@ import { coerce, } from '@metamask/superstruct'; import type { JsonRpcRequest } from '@metamask/utils'; -import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; +import { + base64, + CaipAssetTypeStruct, + parseCaipAssetType, +} from '@metamask/utils'; import { JsonRpcRequestStruct, @@ -36,6 +40,7 @@ import { SwapTransactionXdrStruct, } from '../../api'; import { isSep41Id } from '../../utils'; +import { parseProofOfOwnershipMessage } from './utils'; /** * Enum for the client request method. @@ -48,6 +53,11 @@ export enum ClientRequestMethod { // Standard multichain workflow for bridge SignAndSendTransaction = 'signAndSendTransaction', ComputeFee = 'computeFee', + /** + * Silent proof-of-ownership signing for `@metamask/profile-metrics-controller`. + * SIP-31 client-only. + */ + SignProofOfOwnership = 'signProofOfOwnership', /** -------------------------------- Stellar Specific -------------------------------- */ ChangeTrustOpt = 'changeTrustOpt', } @@ -375,6 +385,69 @@ export const ComputeFeeJsonRpcResponseStruct = array( }), ); +/** + * Validates that a plaintext message follows the proof-of-ownership format: + * `'metamask:proof-of-ownership:{nonce}:{address}'`. + */ +export const ProofOfOwnershipMessageStruct = refine( + string(), + 'ProofOfOwnershipMessage', + (value: string) => { + try { + parseProofOfOwnershipMessage(value); + return true; + } catch (error) { + return error instanceof Error + ? error.message + : 'Invalid proof-of-ownership message'; + } + }, +); + +/** + * Validation struct for the signProofOfOwnership JSON-RPC request. + * Coerces `nonce` and `address` from `message` (clients send only accountId + message). + */ +export const SignProofOfOwnershipJsonRpcRequestStruct = coerce( + assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.SignProofOfOwnership), + params: object({ + accountId: UuidStruct, + message: ProofOfOwnershipMessageStruct, + nonce: nonempty(string()), + address: StellarAddressStruct, + }), + }), + ), + assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.SignProofOfOwnership), + params: object({ + accountId: UuidStruct, + message: ProofOfOwnershipMessageStruct, + }), + }), + ), + (request) => ({ + ...request, + params: { + ...request.params, + ...parseProofOfOwnershipMessage(request.params.message), + }, + }), +); + +/** + * Validation struct for the signProofOfOwnership JSON-RPC response. + * Standard base64 of the 64-byte ed25519 signature (SEP-0053). + */ +export const SignProofOfOwnershipJsonRpcResponseStruct = object({ + signature: nonempty(base64(string())), +}); + /** * A JSON-RPC request with an account resolve parameter. */ @@ -466,3 +539,17 @@ export type ComputeFeeJsonRpcRequest = Infer< export type ComputeFeeJsonRpcResponse = Infer< typeof ComputeFeeJsonRpcResponseStruct >; + +/** + * Type for the signProofOfOwnership JSON-RPC request. + */ +export type SignProofOfOwnershipJsonRpcRequest = Infer< + typeof SignProofOfOwnershipJsonRpcRequestStruct +>; + +/** + * Type for the signProofOfOwnership JSON-RPC response. + */ +export type SignProofOfOwnershipJsonRpcResponse = Infer< + typeof SignProofOfOwnershipJsonRpcResponseStruct +>; diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.test.ts new file mode 100644 index 000000000..82eae88ad --- /dev/null +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.test.ts @@ -0,0 +1,121 @@ +import type { JsonRpcRequest } from '@metamask/utils'; + +import { AccountService, StellarKeyringAccount } from '../../services/account'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import { Wallet, WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; +import { logger } from '../../utils/logger'; +import { AccountResolver } from '../accountResolver'; +import { ClientRequestMethod } from './api'; +import { SignProofOfOwnershipHandler } from './signProofOfOwnership'; + +jest.mock('../../utils/logger'); + +describe('SignProofOfOwnershipHandler', () => { + const accountId = '11111111-1111-4111-8111-111111111111'; + const nonce = 'a1b2c3d4e5f6789012345678'; + + type SetupResult = { + handler: SignProofOfOwnershipHandler; + account: StellarKeyringAccount; + wallet: Wallet; + resolveAccountSpy: jest.SpyInstance; + resolveWalletSpy: jest.SpyInstance; + buildProofMessage: (proofNonce?: string, proofAddress?: string) => string; + createRequest: (message?: string) => JsonRpcRequest; + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function setup(): SetupResult { + const wallet = getTestWallet(); + const account = generateStellarKeyringAccount( + accountId, + wallet.address, + 'entropy-source-1', + 0, + ); + + const { accountService, onChainAccountService, walletService } = + mockOnChainAccountService(); + const accountResolver = new AccountResolver({ + accountService, + onChainAccountService, + walletService, + }); + + const resolveAccountSpy = jest + .spyOn(AccountService.prototype, 'resolveAccount') + .mockResolvedValue({ account }); + + const resolveWalletSpy = jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const handler = new SignProofOfOwnershipHandler({ + logger, + accountResolver, + }); + + const buildProofMessage = ( + proofNonce: string = nonce, + proofAddress: string = wallet.address, + ): string => `metamask:proof-of-ownership:${proofNonce}:${proofAddress}`; + + const createRequest = (message?: string): JsonRpcRequest => ({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: message ?? buildProofMessage(), + }, + }); + + return { + handler, + account, + wallet, + resolveAccountSpy, + resolveWalletSpy, + buildProofMessage, + createRequest, + }; + } + + it('signs the proof message and returns the SEP-0053 base64 signature', async () => { + const { + handler, + wallet, + resolveAccountSpy, + resolveWalletSpy, + buildProofMessage, + createRequest, + } = setup(); + const message = buildProofMessage(); + + const result = await handler.handle(createRequest()); + + expect(resolveAccountSpy).toHaveBeenCalledWith({ accountId }); + expect(resolveWalletSpy).toHaveBeenCalled(); + expect(result).toStrictEqual({ + signature: wallet.signMessage(message), + }); + expect(wallet.verifyMessage(message, result.signature)).toBe(true); + }); + + it('throws if the address in the message does not match the signing account', async () => { + const { handler, wallet, createRequest, buildProofMessage } = setup(); + const otherWallet = getTestWallet(); + const otherAddress = otherWallet.address; + + await expect( + handler.handle(createRequest(buildProofMessage(nonce, otherAddress))), + ).rejects.toThrow( + `Address in proof-of-ownership message (${otherAddress}) does not match signing account address (${wallet.address})`, + ); + }); +}); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.ts new file mode 100644 index 000000000..14b6c4bd6 --- /dev/null +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.ts @@ -0,0 +1,85 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; + +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import type { AccountResolver } from '../accountResolver'; +import { RESOLVE_ACCOUNT_KEYRING_AND_WALLET } from '../accountResolver'; +import { BaseHandler } from '../base'; +import type { + SignProofOfOwnershipJsonRpcRequest, + SignProofOfOwnershipJsonRpcResponse, +} from './api'; +import { + SignProofOfOwnershipJsonRpcRequestStruct, + SignProofOfOwnershipJsonRpcResponseStruct, +} from './api'; +import type { IClientRequestHandler } from './base'; + +/** + * Handles the silent signing of a proof-of-ownership message, of format + * `'metamask:proof-of-ownership:{nonce}:{address}'`. + * + * Used by `@metamask/profile-metrics-controller` to prove wallet control of an + * address. This is a **silent sign** (no user confirmation dialog): it skips + * the usual sign-message security prompt on purpose so the client can collect + * profile-metrics ownership proofs without interrupting the user. + * + */ +export class SignProofOfOwnershipHandler + // We don't need to resolve an on-chain account for this handler, + // so we can use the base handler without any additional options. + extends BaseHandler< + SignProofOfOwnershipJsonRpcRequest, + SignProofOfOwnershipJsonRpcResponse + > + implements IClientRequestHandler +{ + readonly #accountResolver: AccountResolver; + + constructor({ + logger, + accountResolver, + }: { + logger: ILogger; + accountResolver: AccountResolver; + }) { + super({ + logger: createPrefixedLogger(logger, '[🔏 SignProofOfOwnershipHandler]'), + requestStruct: SignProofOfOwnershipJsonRpcRequestStruct, + responseStruct: SignProofOfOwnershipJsonRpcResponseStruct, + }); + this.#accountResolver = accountResolver; + } + + /** + * Resolves the keyring account + wallet (no on-chain activation required) + * and signs the validated proof message. + * + * @param request - The JSON-RPC request containing `accountId`, `message`, + * and coerced `address`. + * @returns `{ signature }` as standard base64 of the SEP-0053 ed25519 signature. + * @throws {InvalidParamsError} If the address in the message does not match + * the signing account. + */ + protected async handleRequest( + request: SignProofOfOwnershipJsonRpcRequest, + ): Promise { + const { accountId, message, address: messageAddress } = request.params; + + const { account, wallet } = await this.#accountResolver.resolveAccount({ + accountId, + options: RESOLVE_ACCOUNT_KEYRING_AND_WALLET, + }); + + if (messageAddress !== account.address) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- InvalidParamsError is the JSON-RPC snap error surface + throw new InvalidParamsError( + `Address in proof-of-ownership message (${messageAddress}) does not match signing account address (${account.address})`, + ); + } + + return { + signature: wallet.signMessage(message), + }; + } +} diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts index b4dec26c8..4b4390e5f 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts @@ -1,3 +1,4 @@ +import { StellarAddressStruct } from '../../api/address'; import { TransactionValidationException } from '../../services/transaction'; import type { Transaction } from '../../services/transaction'; @@ -27,3 +28,46 @@ export function assertRefreshedTransactionFeeNotHigher(params: { ); } } + +/** + * Parses a proof-of-ownership message of format + * `'metamask:proof-of-ownership:{nonce}:{address}'`. + * Splits on the last `:` so opaque nonces may contain colons. + * + * @param message - The plaintext proof-of-ownership message. + * @returns The parsed nonce and Stellar address. + * @throws Error if the message format is invalid. + */ +export function parseProofOfOwnershipMessage(message: string): { + nonce: string; + address: string; +} { + const messagePrefix = 'metamask:proof-of-ownership:'; + + if (!message.startsWith(messagePrefix)) { + throw new Error(`Message must start with "${messagePrefix}"`); + } + + const remainder = message.slice(messagePrefix.length); + const separatorIdx = remainder.lastIndexOf(':'); + if (separatorIdx === -1) { + throw new Error( + 'Message must follow the format "metamask:proof-of-ownership:{nonce}:{address}"', + ); + } + + const nonce = remainder.slice(0, separatorIdx); + const address = remainder.slice(separatorIdx + 1); + + if (nonce === '') { + throw new Error( + 'Proof-of-ownership message must contain a non-empty nonce', + ); + } + + if (!StellarAddressStruct.is(address)) { + throw new Error('Invalid Stellar address in proof-of-ownership message'); + } + + return { nonce, address }; +}