Skip to content
Merged
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
32 changes: 29 additions & 3 deletions @stellar/typescript-wallet-sdk-km/src/Handlers/albedo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ import {
KeyType,
} from "../Types";

// Albedo's tx intent takes a network name, and it supports only these two
// networks.
const albedoNetworks = new Map<string, string>([
[Networks.PUBLIC, "public"],
[Networks.TESTNET, "testnet"],
]);

export const albedoHandler: KeyTypeHandler = {
keyType: KeyType.albedo,
async signTransaction(params: HandlerSignTransactionParams) {
const { transaction, key } = params;
const { transaction, key, custom } = params;

if (key.privateKey !== "") {
throw new Error(
Expand All @@ -24,9 +31,28 @@ export const albedoHandler: KeyTypeHandler = {
);
}

const networkPassphrase =
(custom && custom.networkPassphrase) || key.network || Networks.PUBLIC;

// Albedo signs for the network named here, not the one the transaction
// was built for, so refuse rather than sign for a different network.
if (networkPassphrase !== transaction.networkPassphrase) {
throw new Error(
`Network mismatch: the transaction is for "${transaction.networkPassphrase}", but signing was requested for "${networkPassphrase}"`,
);
}

const network = albedoNetworks.get(networkPassphrase);

if (!network) {
throw new Error(
`Albedo can only sign for Networks.PUBLIC or Networks.TESTNET, not "${networkPassphrase}"`,
);
}

try {
const xdr = transaction.toXdr();
const response = await albedo.tx({ xdr });
const response = await albedo.tx({ xdr, network });

if (!response.signed_envelope_xdr) {
throw new Error("We couldn’t sign the transaction with Albedo.");
Expand All @@ -37,7 +63,7 @@ export const albedoHandler: KeyTypeHandler = {
// to "Transaction" type.
return TransactionBuilder.fromXdr(
response.signed_envelope_xdr,
Networks.PUBLIC,
networkPassphrase,
Comment thread
CassioMG marked this conversation as resolved.
Resolved
) as Transaction;
} catch (error) {
const errorMsg = error.toString();
Expand Down
8 changes: 8 additions & 0 deletions @stellar/typescript-wallet-sdk-km/src/Handlers/freighter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export const freighterHandler: KeyTypeHandler = {
const networkPassphrase =
(custom && custom.networkPassphrase) || key.network || Networks.PUBLIC;

// Freighter signs for the network named here, not the one the transaction
// was built for, so refuse rather than sign for a different network.
if (networkPassphrase !== transaction.networkPassphrase) {
throw new Error(
`Network mismatch: the transaction is for "${transaction.networkPassphrase}", but signing was requested for "${networkPassphrase}"`,
);
}

try {
const response = await freighterApi.signTransaction(transaction.toXdr(), {
networkPassphrase,
Expand Down
3 changes: 2 additions & 1 deletion @stellar/typescript-wallet-sdk-km/src/Types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export interface BaseKey {
extra?: any;
path?: string;
publicKey: string;
// if the network is not set, the key is assumed to be on Networks.PUBLIC
// if the network is not set, KeyManager assumes the key is on its default
// network passphrase (Networks.PUBLIC unless configured otherwise)
network?: string;
type: KeyType | string;
}
Expand Down
24 changes: 21 additions & 3 deletions @stellar/typescript-wallet-sdk-km/src/keyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ export class KeyManager {
}

/**
* Set the default network passphrase
* Set the default network passphrase, used for keys that don't set their
* own network.
* @param {string} passphrase - The passphrase.
*/
public setDefaultNetworkPassphrase(passphrase: string) {
Expand Down Expand Up @@ -184,7 +185,10 @@ export class KeyManager {
* @param {string} params.id - Key to sign with. The id is computed as
* `sha1(private key + public key)`.
* @param {string} params.password - the key password
* @param {{[key: string]: any}} [params.custom] - custom arguments
* @param {{[key: string]: any}} [params.custom] - custom arguments. For keys
* held by an external wallet (Freighter, Albedo), `networkPassphrase` is
* the network to sign for. It defaults to the key's network, then to the
* default network passphrase, and must match the transaction's network.
* @returns {Promise<Transaction>} Signed transaction
*/
public async signTransaction(
Expand All @@ -208,10 +212,20 @@ export class KeyManager {
}

const keyHandler = this.keyHandlerMap[key.type];

// Handlers that sign through an external wallet (Freighter, Albedo) can't
// see defaultNetworkPassphrase, so fill it in for keys without a network.
// Those handlers refuse a transaction built for any other network.
const signedTransaction = await keyHandler.signTransaction({
transaction,
key,
custom,
custom: {
...custom,
networkPassphrase:
(custom && custom.networkPassphrase) ||
key.network ||
this.defaultNetworkPassphrase,
},
});
return signedTransaction;
}
Expand Down Expand Up @@ -388,9 +402,13 @@ export class KeyManager {

const keyHandler = this.keyHandlerMap[key.type];

// Handlers that sign through an external wallet (Freighter, Albedo) can't
// see defaultNetworkPassphrase, so tell them which network the challenge
// was validated against.
const signedTransaction = await keyHandler.signTransaction({
transaction,
key,
custom: { networkPassphrase: keyNetwork },
});

const signedTransactionXDR: string = signedTransaction
Expand Down
183 changes: 183 additions & 0 deletions @stellar/typescript-wallet-sdk-km/test/albedo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import {
Keypair,
Networks,
Account,
TransactionBuilder,
Operation,
Transaction,
Asset,
BASE_FEE,
WebAuth,
} from "@stellar/stellar-sdk";
import albedo from "@albedo-link/intent";

import { albedoSigningWith } from "./fixtures/albedo";
import { albedoHandler } from "../src/Handlers/albedo";
import { Key, KeyType } from "../src/Types";

jest.mock("@albedo-link/intent", () => ({
__esModule: true,
default: {
tx: jest.fn(),
},
}));

const mockTx = albedo.tx as jest.Mock;

function buildTestTransaction(
source: Keypair,
networkPassphrase: string,
): Transaction {
return new TransactionBuilder(new Account(source.publicKey(), "100"), {
fee: BASE_FEE,
networkPassphrase,
})
.addOperation(
Operation.payment({
destination: Keypair.random().publicKey(),
asset: Asset.native(),
amount: "10",
}),
)
.setTimeout(30)
.build();
}

function makeAlbedoKey(signer: Keypair, overrides: Partial<Key> = {}): Key {
return {
id: "albedo-test-key",
privateKey: "",
publicKey: signer.publicKey(),
type: KeyType.albedo,
...overrides,
};
}

describe("albedoHandler", () => {
let signer: Keypair;

beforeEach(() => {
jest.clearAllMocks();
signer = Keypair.random();
mockTx.mockImplementation(albedoSigningWith(signer));
});

it("signs for the network the key is scoped to", async () => {
const tx = buildTestTransaction(signer, Networks.TESTNET);
const key = makeAlbedoKey(signer, { network: Networks.TESTNET });

const result = await albedoHandler.signTransaction({
transaction: tx,
key,
});

expect(result.networkPassphrase).toBe(Networks.TESTNET);
expect(WebAuth.verifyTxSignedBy(result, signer.publicKey())).toBe(true);
});

it("signs for custom.networkPassphrase over the key's network", async () => {
const tx = buildTestTransaction(signer, Networks.TESTNET);
const key = makeAlbedoKey(signer, { network: Networks.PUBLIC });

const result = await albedoHandler.signTransaction({
transaction: tx,
key,
custom: { networkPassphrase: Networks.TESTNET },
});

expect(result.networkPassphrase).toBe(Networks.TESTNET);
expect(WebAuth.verifyTxSignedBy(result, signer.publicKey())).toBe(true);
});

it("asks Albedo for the public network when no network is set", async () => {
const tx = buildTestTransaction(signer, Networks.PUBLIC);
const key = makeAlbedoKey(signer);

const result = await albedoHandler.signTransaction({
transaction: tx,
key,
});

expect(mockTx).toHaveBeenCalledWith(
expect.objectContaining({ xdr: tx.toXdr(), network: "public" }),
);
expect(result.networkPassphrase).toBe(Networks.PUBLIC);
expect(WebAuth.verifyTxSignedBy(result, signer.publicKey())).toBe(true);
});

it.each([
["Networks.FUTURENET", Networks.FUTURENET],
["a private network passphrase", "Private Network ; October 2026"],
])("refuses %s without opening Albedo", async (_, networkPassphrase) => {
const tx = buildTestTransaction(signer, networkPassphrase);
const key = makeAlbedoKey(signer, { network: networkPassphrase });

await expect(
albedoHandler.signTransaction({ transaction: tx, key }),
).rejects.toThrow(networkPassphrase);
expect(mockTx).not.toHaveBeenCalled();
});

it.each([
{
setup: "a key without a network",
txNetwork: Networks.TESTNET,
keyNetwork: undefined,
customNetwork: undefined,
},
{
setup: "a key scoped to another network",
txNetwork: Networks.PUBLIC,
keyNetwork: Networks.TESTNET,
customNetwork: undefined,
},
{
setup: "custom.networkPassphrase for another network",
txNetwork: Networks.TESTNET,
keyNetwork: Networks.TESTNET,
customNetwork: Networks.PUBLIC,
},
])(
"refuses a transaction for another network ($setup) without opening Albedo",
async ({ txNetwork, keyNetwork, customNetwork }) => {
const tx = buildTestTransaction(signer, txNetwork);
const key = makeAlbedoKey(signer, { network: keyNetwork });

await expect(
albedoHandler.signTransaction({
transaction: tx,
key,
custom: { networkPassphrase: customNetwork },
}),
).rejects.toThrow(`"${txNetwork}"`);
expect(mockTx).not.toHaveBeenCalled();
},
);

it("refuses a key that holds a private key without opening Albedo", async () => {
const tx = buildTestTransaction(signer, Networks.TESTNET);
const key = makeAlbedoKey(signer, { privateKey: signer.secret() });

await expect(
albedoHandler.signTransaction({ transaction: tx, key }),
).rejects.toThrow(signer.publicKey());
expect(mockTx).not.toHaveBeenCalled();
});

it("throws when Albedo returns no signed envelope", async () => {
mockTx.mockResolvedValue({
intent: "tx",
pubkey: signer.publicKey(),
network: "testnet",
xdr: "",
tx_hash: "",
signed_envelope_xdr: "",
});
const tx = buildTestTransaction(signer, Networks.TESTNET);
const key = makeAlbedoKey(signer, { network: Networks.TESTNET });

await expect(
albedoHandler.signTransaction({ transaction: tx, key }),
).rejects.toThrow("We couldn’t sign the transaction with Albedo");
});
});
39 changes: 39 additions & 0 deletions @stellar/typescript-wallet-sdk-km/test/fixtures/albedo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
Keypair,
Networks,
Transaction,
TransactionBuilder,
xdr,
} from "@stellar/stellar-sdk";

// The tx intent's `network` param takes a network name or that network's
// passphrase, and Albedo signs with the matching passphrase.
const albedoPassphrases: { [network: string]: string } = {
public: Networks.PUBLIC,
testnet: Networks.TESTNET,
[Networks.PUBLIC]: Networks.PUBLIC,
[Networks.TESTNET]: Networks.TESTNET,
};

// Stands in for albedo.link answering a tx intent: it signs the XDR with the
// user's account for the requested network ("public" when none is given) and
// echoes that network back.
export function albedoSigningWith(signer: Keypair) {
return ({ xdr: envelope, network }: { xdr: string; network?: string }) => {
const responseNetwork = network || "public";
const tx = TransactionBuilder.fromXdr(
envelope,
albedoPassphrases[responseNetwork],
) as Transaction;
tx.sign(signer);

return Promise.resolve({
intent: "tx",
pubkey: signer.publicKey(),
network: responseNetwork,
xdr: envelope,
tx_hash: xdr.encodeBytes(tx.hash(), "hex"),
signed_envelope_xdr: tx.toXdr(),
});
};
}
Loading
Loading