Skip to content
Merged
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 `exportAccount` keyring method for base32 Stellar secret-seed export ([#187](https://github.com/MetaMask/internal-snaps/pull/187))
- 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))
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,78 @@
# 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`); the response is 0x-prefixed hex for the identity auth API.

## 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 }` — SEP-0053 Stellar signed-message ed25519 signature as hex, plus a `0x` prefix for the identity auth API:
- The raw signature is **64 bytes** → **128** lowercase hex characters (the `0x` prefix is **not** part of those 64 bytes).
- Wire form: `0x` + 128 hex chars (130 characters total), e.g. validated by `/^0x[0-9a-f]{128}$/`.

## 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, 'hex')` returns the 64-byte SEP-0053 signature as 128 hex chars (no `0x`), then the handler prefixes `0x`.

## 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, hex)
Wallet-->>Handler: 64-byte signature as 128 hex chars (no 0x)
Handler-->>Client: { signature } (0x + 128 hex chars)
```
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 @@ -292,6 +293,11 @@ const computeFeeHandler = new ComputeFeeHandler({
transactionService,
});

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

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

const clientRequestHandler = new ClientRequestHandler({
Expand Down
154 changes: 154 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,155 @@ 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 0x-prefixed 64-byte hex signature', () => {
expect(() =>
assert(
{
signature: `0x${'ab'.repeat(64)}`,
},
SignProofOfOwnershipJsonRpcResponseStruct,
),
).not.toThrow();
});

it.each([
{
signature:
'fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==',
},
{ signature: 'ab'.repeat(64) }, // missing 0x
{ signature: `0x${'ab'.repeat(63)}` }, // 63 bytes
{ signature: `0x${'ab'.repeat(65)}` }, // 65 bytes
{ signature: `0x${'AB'.repeat(64)}` }, // uppercase
{ signature: 'not!!!valid-hex' },
{ signature: '' },
{},
])('rejects an invalid signProofOfOwnership response', (response) => {
expect(() =>
assert(response, SignProofOfOwnershipJsonRpcResponseStruct),
).toThrow(StructError);
});
});
Loading