Skip to content

Commit

Permalink
Merge pull request #7789 from LedgerHQ/support/qaa_173_speculos_llm_l…
Browse files Browse the repository at this point in the history
…ocal

[QAA-173]Speculos x LLM (local test)
  • Loading branch information
abdurrahman-ledger authored Sep 12, 2024
2 parents b9bd0f2 + fcd9de7 commit 12472d1
Show file tree
Hide file tree
Showing 14 changed files with 463 additions and 7 deletions.
2 changes: 1 addition & 1 deletion apps/ledger-live-mobile/.env.mock
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ ADJUST_APP_TOKEN=cbxft2ch7wn4
BRAZE_ANDROID_API_KEY="be5e1bc8-43f1-4864-b097-076a3c693a43"
BRAZE_IOS_API_KEY="e0a7dfaf-fc30-48f6-b998-01dbebbb73a4"
BRAZE_CUSTOM_ENDPOINT="sdk.fra-02.braze.eu"
FEATURE_FLAGS={"ratingsPrompt":{"enabled":false},"brazePushNotifications":{"enabled":false}}
FEATURE_FLAGS={"ratingsPrompt":{"enabled":false},"brazePushNotifications":{"enabled":false},"llmAnalyticsOptInPrompt":{"enabled":false}}
# Fix random iOS app crash https://github.com/wix/Detox/pull/3135
SIMCTL_CHILD_NSZombieEnabled=YES
MOCK_REMOTE_LIVE_MANIFEST=[{"name":"Dummy Wallet API Live App","homepageUrl":"https://developers.ledger.com/","icon":"","platforms":["ios","android","desktop"],"apiVersion":"2.0.0","manifestVersion":"1","branch":"stable","categories":["tools"],"currencies":"*","content":{"shortDescription":{"en":"App to test the Wallet API"},"description":{"en":"App to test the Wallet API with Playwright"}},"permissions":["account.list","account.receive","account.request","currency.list","device.close","device.exchange","device.transport","message.sign","transaction.sign","transaction.signAndBroadcast","storage.set","storage.get","bitcoin.getXPub","wallet.capabilities","wallet.userId","wallet.info"],"domains":["http://*"],"visibility":"complete","id":"dummy-live-app","url":"http://localhost:52619"}]
34 changes: 31 additions & 3 deletions apps/ledger-live-mobile/e2e/bridge/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@ import { Platform } from "react-native";
import invariant from "invariant";
import { Subject } from "rxjs";
import { store } from "~/context/store";
import { importSettings } from "~/actions/settings";
import { importSettings, setLastConnectedDevice } from "~/actions/settings";
import { importStore as importAccountsRaw } from "~/actions/accounts";
import { acceptGeneralTerms } from "~/logic/terms";
import { navigate } from "~/rootnavigation";
import { importBle } from "~/actions/ble";
import { addKnownDevice, importBle, removeKnownDevice } from "~/actions/ble";
import { LaunchArguments } from "react-native-launch-arguments";
import { DeviceEventEmitter } from "react-native";
import logReport from "../../src/log-report";
import { MessageData, ServerData, mockDeviceEventSubject } from "./types";
import { getAllEnvs } from "@ledgerhq/live-env";
import { getAllEnvs, setEnv } from "@ledgerhq/live-env";
import { getAllFeatureFlags } from "@ledgerhq/live-common/e2e/index";
import { DeviceModelId } from "@ledgerhq/devices";

export const e2eBridgeClient = new Subject<MessageData>();

Expand Down Expand Up @@ -118,6 +119,33 @@ function onMessage(event: WebSocketMessageEvent) {
});
break;
}
case "addKnownSpeculos": {
const address = msg.payload;
const model = DeviceModelId.nanoX;
store.dispatch(
setLastConnectedDevice({
deviceId: `httpdebug|ws://${address}`,
deviceName: `${address}`,
wired: false,
modelId: model,
}),
);
store.dispatch(
addKnownDevice({
id: `httpdebug|ws://${address}`,
name: `${address}`,
modelId: model,
}),
);
setEnv("DEVICE_PROXY_URL", address);
break;
}
case "removeKnownSpeculos": {
const address = msg.payload;
store.dispatch(removeKnownDevice(`httpdebug|ws://${address}`));
setEnv("DEVICE_PROXY_URL", "");
break;
}
default:
break;
}
Expand Down
265 changes: 265 additions & 0 deletions apps/ledger-live-mobile/e2e/bridge/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
/* eslint-disable global-require */
import { log, listen } from "@ledgerhq/logs";
import { open, registerTransportModule, TransportModule } from "@ledgerhq/live-common/hw/index";
import http from "http";
import express from "express";
import cors from "cors";
import WebSocket from "ws";
import bodyParser from "body-parser";
import os from "os";
import { Observable, Subscription } from "rxjs";
import SpeculosHttpTransport, {
SpeculosHttpTransportOpts,
} from "@ledgerhq/hw-transport-node-speculos-http";
import { retry } from "@ledgerhq/live-common/promise";
import { Buffer } from "buffer";
import { findFreePort } from "./server";
import { getEnv } from "@ledgerhq/live-env";
import invariant from "invariant";

const proxySubscriptions: [string, Subscription][] = [];

let transport: TransportModule;

interface ProxyOptions {
device: string;
port: string;
silent?: boolean;
verbose?: boolean;
speculosApiPort: string;
}

export async function startProxy(speculosApiPort?: string, proxyPort?: string): Promise<string> {
if (!proxyPort) proxyPort = (await findFreePort()).toString();
if (!speculosApiPort) speculosApiPort = getEnv("SPECULOS_API_PORT").toString();
invariant(speculosApiPort, "E2E Proxy : speculosApiPort is not defined");
invariant(proxyPort, "E2E Proxy : proxyPort is not defined");

return new Promise((resolve, reject) => {
const options: ProxyOptions = {
device: `speculos-${proxyPort}`,
port: proxyPort,
silent: true,
verbose: false,
speculosApiPort,
};

const observable = job(options);

proxySubscriptions.push([
proxyPort,
observable.subscribe({
next: message => {
if (Array.isArray(message)) {
const address = `${message[0]}:${proxyPort}`;
console.warn("Proxy started on :", address);
resolve(address);
} else {
console.warn(message);
}
},
error: err => {
console.error("Error:", err);
reject(err);
},
complete: () => console.warn("Proxy stopped."),
}),
]);
});
}

export function closeProxy(proxyPort?: string) {
if (!proxyPort) {
for (const [, subscription] of proxySubscriptions) {
subscription.unsubscribe();
}
return;
}
const proxySubscription = proxySubscriptions.find(([string]) => string === proxyPort)?.[1];
if (proxySubscription) {
proxySubscription.unsubscribe();
proxySubscriptions.splice(proxySubscriptions.indexOf([proxyPort, proxySubscription]));
}
}

const job = ({ device, port, silent, verbose, speculosApiPort }: ProxyOptions) =>
new Observable(observer => {
const req: SpeculosHttpTransportOpts = {
apiPort: speculosApiPort,
};

transport = {
id: `speculos-http-${speculosApiPort}`,
open: id => (id.includes(port) ? retry(() => SpeculosHttpTransport.open(req)) : null),
disconnect: () => Promise.resolve(),
};
registerTransportModule(transport);

const unsubscribe = listen(logMessage => {
if (verbose) {
observer.next(`${logMessage.type}: ${logMessage.message}`);
} else if (!silent && logMessage.type === "proxy") {
observer.next(logMessage.message);
}
});

const Transport = {
open: () => open(device || ""),
create: () => open(device || ""),
};

const ifaces = os.networkInterfaces();
const ips = Object.keys(ifaces)
.reduce<string[]>((acc, ifname) => {
const addresses =
ifaces[ifname]
?.filter(iface => iface.family === "IPv4" && !iface.internal)
.map(iface => iface.address) || [];
return acc.concat(addresses);
}, [])
.filter(Boolean);

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

app.use(cors());
app.get("/", (_, res) => res.sendStatus(200));

let pending = false;
app.post("/", bodyParser.json(), async (req, res) => {
if (!req.body) return res.sendStatus(400);

if (pending) {
return res.status(400).json({ error: "An exchange query is already pending" });
}

pending = true;
let data = null;
let error: Error | null = null;

try {
const transport = await Transport.open();
try {
data = await transport.exchange(Buffer.from(req.body.apduHex, "hex"));
} finally {
transport.close();
}
} catch (e) {
error = e as Error;
}

pending = false;
res.json({ data, error });

const logArgs = ["proxy", "HTTP:", req.body.apduHex, "=>"];
if (data) {
log("proxy", ...logArgs, data.toString("hex"));
} else {
log("proxy", ...logArgs, error);
}

if (error?.name === "RecordStoreWrongAPDU") {
console.error(error.message);
process.exit(1);
}
});

let wsIndex = 0;
let wsBusyIndex = 0;
wss.on("connection", (ws: WebSocket) => {
const index = ++wsIndex;
let transport: SpeculosHttpTransport;
let transportP: Promise<SpeculosHttpTransport>;
let destroyed = false;

const onClose = async () => {
if (destroyed) return;
destroyed = true;

if (wsBusyIndex === index) {
log("proxy", `WS(${index}): close`);
await transportP.then(
t => t.close(),
() => {},
);
wsBusyIndex = 0;
}
};

ws.on("close", onClose);
ws.on("message", async (data, isBinary) => {
if (destroyed) return;

const apduHex = isBinary ? data : data.toString();
if (apduHex === "open") {
if (wsBusyIndex) {
ws.send(JSON.stringify({ error: "WebSocket is busy (previous session not closed)" }));
ws.close();
destroyed = true;
return;
}

transportP = Transport.open() as Promise<SpeculosHttpTransport>;
wsBusyIndex = index;
log("proxy", `WS(${index}): opening...`);

try {
transport = await transportP;
transport.on("disconnect", () => ws.close());
log("proxy", `WS(${index}): opened!`);
ws.send(JSON.stringify({ type: "opened" }));
} catch (e) {
log("proxy", `WS(${index}): open failed!`, e);
ws.send(JSON.stringify({ error: (e as Error).message }));
ws.close();
}

return;
}

if (wsBusyIndex !== index) {
console.warn("Ignoring message because transport is busy");
return;
}

if (!transport) {
console.warn("Received message before device was opened");
return;
}

try {
const response = await transport.exchange(Buffer.from(apduHex as string, "hex"));
log("proxy", `WS(${index}): ${apduHex} => ${response.toString("hex")}`);
if (!destroyed) {
ws.send(JSON.stringify({ type: "response", data: response.toString("hex") }));
}
} catch (e) {
log("proxy", `WS(${index}): ${apduHex} =>`, e);
if (!destroyed) {
ws.send(JSON.stringify({ type: "error", error: (e as Error).message }));
}

if ((e as Error).name === "RecordStoreWrongAPDU") {
console.error((e as Error).message);
process.exit(1);
}
}
});
});

const proxyUrls = ["localhost", ...ips].map(ip => `ws://${ip}:${port || "8435"}`);
proxyUrls.forEach(url => log("proxy", `DEVICE_PROXY_URL=${url}`));

server.listen(port || "8435", () => {
log("proxy", `\nNano S proxy started on ${ips[0]}\n`);
observer.next(ips);
});

return () => {
unsubscribe();
wss.close(() => log("proxy", "WebSocket server closed."));
server.close(() => log("proxy", "HTTP server closed."));
console.warn(`Proxy stopped on ${port}`);
};
});
8 changes: 8 additions & 0 deletions apps/ledger-live-mobile/e2e/bridge/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,14 @@ function fetchData(message: MessageData): Promise<string> {
});
}

export async function addKnownSpeculos(proxyAddress: string) {
await postMessage({ type: "addKnownSpeculos", id: uniqueId(), payload: proxyAddress });
}

export async function removeKnownSpeculos(id: string) {
await postMessage({ type: "removeKnownSpeculos", id: uniqueId(), payload: id });
}

function onMessage(messageStr: string) {
const msg: ServerData = JSON.parse(messageStr);
log(`Message received ${msg.type}`);
Expand Down
2 changes: 2 additions & 0 deletions apps/ledger-live-mobile/e2e/bridge/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export type MessageData =
| { type: "mockDeviceEvent"; id: string; payload: MockDeviceEvent[] }
| { type: "acceptTerms"; id: string }
| { type: "addUSB"; id: string; payload: DeviceUSB }
| { type: "addKnownSpeculos"; id: string; payload: string }
| { type: "removeKnownSpeculos"; id: string; payload: string }
| { type: "getLogs"; id: string }
| { type: "getFlags"; id: string }
| { type: "getEnvs"; id: string }
Expand Down
Loading

0 comments on commit 12472d1

Please sign in to comment.