Skip to content
1 change: 1 addition & 0 deletions packages/stellar-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
- Add `@metamask/snap-networks-utils` `^1.0.0` ([#182](https://github.com/MetaMask/internal-snaps/pull/182))
- Use the shared `Logger`
Expand Down
1 change: 1 addition & 0 deletions packages/stellar-wallet-snap/docs/use-cases/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 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}` (see [Message format](#message-format))
- `nonce`, `address` — coerced from `message` internally (clients do not send these)

**Response**

- `{ signature }` — standard base64 of the 64-byte ed25519 signature (SEP-0053)

## Message format

Parsed by [`parseProofOfOwnershipMessage`](../../../src/handlers/clientRequest/utils.ts) during request validation:

- Prefix must be exactly `metamask:proof-of-ownership:` (case-sensitive).
- `{nonce}` is non-empty and may contain `:` characters; parsing splits on the **last** `:` in the remainder.
- `{address}` must be a valid Stellar strkey (G… public key).

Example: `metamask:proof-of-ownership:ns:abc:123:GBX…` → nonce `ns:abc:123`, address `GBX…`.

## 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) |
| `AccountService` | `services/account` | Keyring account lookup (via resolver) |
| `WalletService` / `Wallet` | `services/wallet` | Signing key material + SEP-0053 `signMessage` |

No confirmation UI or network calls.

## 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** — `AccountResolver.resolveAccount` with `RESOLVE_ACCOUNT_KEYRING_AND_WALLET` loads keyring account and wallet only. The signing 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 }
```
7 changes: 7 additions & 0 deletions packages/stellar-wallet-snap/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -291,6 +292,11 @@ const computeFeeHandler = new ComputeFeeHandler({
transactionService,
});

const signProofOfOwnershipHandler = new SignProofOfOwnershipHandler({
logger,
accountResolver,
});

const clientRequestMethodHandlers: Record<
ClientRequestMethod,
IClientRequestHandler
Expand All @@ -301,6 +307,7 @@ const clientRequestMethodHandlers: Record<
[ClientRequestMethod.ConfirmSend]: confirmSendHandler,
[ClientRequestMethod.SignAndSendTransaction]: signAndSendTransactionHandler,
[ClientRequestMethod.ComputeFee]: computeFeeHandler,
[ClientRequestMethod.SignProofOfOwnership]: signProofOfOwnershipHandler,
};

const clientRequestHandler = new ClientRequestHandler({
Expand Down
146 changes: 146 additions & 0 deletions packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
ConfirmSendJsonRpcResponseStruct,
SignAndSendTransactionJsonRpcRequestStruct,
SignAndSendTransactionJsonRpcResponseStruct,
SignProofOfOwnershipJsonRpcRequestStruct,
SignProofOfOwnershipJsonRpcResponseStruct,
} from './api';

const accountId = '11111111-1111-4111-8111-111111111111';
Expand Down Expand Up @@ -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);
},
);
});
89 changes: 88 additions & 1 deletion packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -36,6 +40,7 @@ import {
SwapTransactionXdrStruct,
} from '../../api';
import { isSep41Id } from '../../utils';
import { parseProofOfOwnershipMessage } from './utils';

/**
* Enum for the client request method.
Expand All @@ -48,6 +53,11 @@ export const ClientRequestMethod = {
// Standard multichain workflow for bridge
SignAndSendTransaction: 'signAndSendTransaction',
ComputeFee: 'computeFee',
/**
* Silent proof-of-ownership signing for `@metamask/profile-metrics-controller`.
* SIP-31 client-only.
*/
Comment thread
Copilot marked this conversation as resolved.
SignProofOfOwnership: 'signProofOfOwnership',
/** -------------------------------- Stellar Specific -------------------------------- */
ChangeTrustOpt: 'changeTrustOpt',
} as const;
Expand Down Expand Up @@ -383,6 +393,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())),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the Auth API expects base64 here. Can you verify with the auth team (most probably @dovydas55) that they expect 0x-prefixed hex for Stellar as well?

SEP-0053 is the right signing scheme, this is only about encoding the 64-byte sig on the wire. We had the same issue with Solana base58 and had to transcode to 0x hex: #617

If they confirm hex, this return and SignProofOfOwnershipJsonRpcResponseStruct in api.ts both need to change.

});

/**
* A JSON-RPC request with an account resolve parameter.
*/
Expand Down Expand Up @@ -474,3 +547,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
>;
Loading