gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] branch master updated: implement fakebank withdrawal


From: gnunet
Subject: [taler-wallet-core] branch master updated: implement fakebank withdrawal
Date: Thu, 14 Oct 2021 11:36:48 +0200

This is an automated email from the git hooks/post-receive script.

dold pushed a commit to branch master
in repository wallet-core.

The following commit(s) were added to refs/heads/master by this push:
     new c5326486 implement fakebank withdrawal
c5326486 is described below

commit c53264869451ccbfbaf1976e01df8c7636163068
Author: Florian Dold <florian@dold.me>
AuthorDate: Thu Oct 14 11:36:43 2021 +0200

    implement fakebank withdrawal
---
 packages/taler-util/src/walletTypes.ts             | 28 +++++++--
 packages/taler-wallet-cli/src/index.ts             | 24 ++++++-
 .../src/integrationtests/harness.ts                | 61 ++++++++++++++++++
 .../src/integrationtests/helpers.ts                | 33 +++++++---
 ...rawal-manual.ts => test-withdrawal-fakebank.ts} | 70 +++++++++++++--------
 .../src/integrationtests/test-withdrawal-manual.ts |  4 --
 .../src/integrationtests/testrunner.ts             |  4 +-
 packages/taler-wallet-core/src/wallet-api-types.ts | 13 +++-
 packages/taler-wallet-core/src/wallet.ts           | 73 +++++++++++++++++++---
 9 files changed, 253 insertions(+), 57 deletions(-)

diff --git a/packages/taler-util/src/walletTypes.ts 
b/packages/taler-util/src/walletTypes.ts
index 63ece1e6..6e68ee08 100644
--- a/packages/taler-util/src/walletTypes.ts
+++ b/packages/taler-util/src/walletTypes.ts
@@ -590,11 +590,11 @@ export interface GetExchangeTosResult {
    * if any.
    */
   acceptedEtag: string | undefined;
-  
+
   /**
    * Accepted content type
    */
-   contentType: string;
+  contentType: string;
 }
 
 export interface TestPayArgs {
@@ -658,9 +658,9 @@ export interface GetExchangeTosRequest {
 
 export const codecForGetExchangeTosRequest = (): Codec<GetExchangeTosRequest> 
=>
   buildCodecForObject<GetExchangeTosRequest>()
-  .property("exchangeBaseUrl", codecForString())
-  .property("acceptedFormat", codecOptional(codecForList(codecForString())))
-  .build("GetExchangeTosRequest");
+    .property("exchangeBaseUrl", codecForString())
+    .property("acceptedFormat", codecOptional(codecForList(codecForString())))
+    .build("GetExchangeTosRequest");
 
 export interface AcceptManualWithdrawalRequest {
   exchangeBaseUrl: string;
@@ -734,7 +734,10 @@ export const codecForGetExchangeWithdrawalInfo = (): 
Codec<GetExchangeWithdrawal
   buildCodecForObject<GetExchangeWithdrawalInfo>()
     .property("exchangeBaseUrl", codecForString())
     .property("amount", codecForAmountJson())
-    .property("tosAcceptedFormat", 
codecOptional(codecForList(codecForString())))
+    .property(
+      "tosAcceptedFormat",
+      codecOptional(codecForList(codecForString())),
+    )
     .build("GetExchangeWithdrawalInfo");
 
 export interface AbortProposalRequest {
@@ -1029,3 +1032,16 @@ export const codecForSetWalletDeviceIdRequest = (): 
Codec<SetWalletDeviceIdReque
   buildCodecForObject<SetWalletDeviceIdRequest>()
     .property("walletDeviceId", codecForString())
     .build("SetWalletDeviceIdRequest");
+
+export interface WithdrawFakebankRequest {
+  amount: AmountString;
+  exchange: string;
+  bank: string;
+}
+
+export const codecForWithdrawFakebankRequest = (): 
Codec<WithdrawFakebankRequest> =>
+  buildCodecForObject<WithdrawFakebankRequest>()
+    .property("amount", codecForAmountString())
+    .property("bank", codecForString())
+    .property("exchange", codecForString())
+    .build("WithdrawFakebankRequest");
diff --git a/packages/taler-wallet-cli/src/index.ts 
b/packages/taler-wallet-cli/src/index.ts
index 0985ba88..a5e129d9 100644
--- a/packages/taler-wallet-cli/src/index.ts
+++ b/packages/taler-wallet-cli/src/index.ts
@@ -634,6 +634,29 @@ const advancedCli = walletCli.subcommand("advancedArgs", 
"advanced", {
     "Subcommands for advanced operations (only use if you know what you're 
doing!).",
 });
 
+advancedCli
+  .subcommand("withdrawFakebank", "withdraw-fakebank", {
+    help: "Withdraw via a fakebank.",
+  })
+  .requiredOption("exchange", ["--exchange"], clk.STRING, {
+    help: "Base URL of the exchange to use",
+  })
+  .requiredOption("amount", ["--amount"], clk.STRING, {
+    help: "Amount to withdraw (before fees)."
+  })
+  .requiredOption("bank", ["--bank"], clk.STRING, {
+    help: "Base URL of the Taler fakebank service.",
+  })
+  .action(async (args) => {
+    await withWallet(args, async (wallet) => {
+      await wallet.client.call(WalletApiOperation.WithdrawFakebank, {
+        amount: args.withdrawFakebank.amount,
+        bank: args.withdrawFakebank.bank,
+        exchange: args.withdrawFakebank.exchange,
+      });
+    });
+  });
+
 advancedCli
   .subcommand("manualWithdrawalDetails", "manual-withdrawal-details", {
     help: "Query withdrawal fees.",
@@ -1064,6 +1087,5 @@ export function main() {
     logger.warn("Allowing withdrawal of late denominations for debugging");
     walletCoreDebugFlags.denomselAllowLate = true;
   }
-  logger.trace(`running wallet-cli with`, process.argv);
   walletCli.run();
 }
diff --git a/packages/taler-wallet-cli/src/integrationtests/harness.ts 
b/packages/taler-wallet-cli/src/integrationtests/harness.ts
index a3a6e9e1..6644e567 100644
--- a/packages/taler-wallet-cli/src/integrationtests/harness.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/harness.ts
@@ -395,6 +395,11 @@ export interface BankConfig {
   maxDebt?: string;
 }
 
+export interface FakeBankConfig {
+  currency: string;
+  httpPort: number;
+}
+
 function setTalerPaths(config: Configuration, home: string) {
   config.setString("paths", "taler_home", home);
   // We need to make sure that the path of taler_runtime_dir isn't too long,
@@ -714,6 +719,62 @@ export class BankService implements BankServiceInterface {
   }
 }
 
+export class FakeBankService {
+  proc: ProcessWrapper | undefined;
+
+  static fromExistingConfig(gc: GlobalTestState): FakeBankService {
+    const cfgFilename = gc.testDir + "/bank.conf";
+    console.log("reading fakebank config from", cfgFilename);
+    const config = Configuration.load(cfgFilename);
+    const bc: FakeBankConfig = {
+      currency: config.getString("taler", "currency").required(),
+      httpPort: config.getNumber("bank", "http_port").required(),
+    };
+    return new FakeBankService(gc, bc, cfgFilename);
+  }
+
+  static async create(
+    gc: GlobalTestState,
+    bc: FakeBankConfig,
+  ): Promise<FakeBankService> {
+    const config = new Configuration();
+    setTalerPaths(config, gc.testDir + "/talerhome");
+    config.setString("taler", "currency", bc.currency);
+    config.setString("bank", "http_port", `${bc.httpPort}`);
+    const cfgFilename = gc.testDir + "/bank.conf";
+    config.write(cfgFilename);
+    return new FakeBankService(gc, bc, cfgFilename);
+  }
+
+  get baseUrl(): string {
+    return `http://localhost:${this.bankConfig.httpPort}/`;
+  }
+
+  get port() {
+    return this.bankConfig.httpPort;
+  }
+
+  private constructor(
+    private globalTestState: GlobalTestState,
+    private bankConfig: FakeBankConfig,
+    private configFile: string,
+  ) {}
+
+  async start(): Promise<void> {
+    this.proc = this.globalTestState.spawnService(
+      "taler-fakebank-run",
+      ["-c", this.configFile],
+      "fakebank",
+    );
+  }
+
+  async pingUntilAvailable(): Promise<void> {
+    // Fakebank doesn't have "/config", so we ping just "/".
+    const url = `http://localhost:${this.bankConfig.httpPort}/`;
+    await pingProc(this.proc, url, "bank");
+  }
+}
+
 export interface BankUser {
   username: string;
   password: string;
diff --git a/packages/taler-wallet-cli/src/integrationtests/helpers.ts 
b/packages/taler-wallet-cli/src/integrationtests/helpers.ts
index 1fdc3678..3b4e1643 100644
--- a/packages/taler-wallet-cli/src/integrationtests/helpers.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/helpers.ts
@@ -353,13 +353,22 @@ export async function makeTestPayment(
   const { wallet, merchant } = args;
   const instance = args.instance ?? "default";
 
-  const orderResp = await MerchantPrivateApi.createOrder(merchant, instance, {
-    order: args.order,
-  }, auth);
+  const orderResp = await MerchantPrivateApi.createOrder(
+    merchant,
+    instance,
+    {
+      order: args.order,
+    },
+    auth,
+  );
 
-  let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, 
{
-    orderId: orderResp.order_id,
-  }, auth);
+  let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
+    merchant,
+    {
+      orderId: orderResp.order_id,
+    },
+    auth,
+  );
 
   t.assertTrue(orderStatus.order_status === "unpaid");
 
@@ -384,10 +393,14 @@ export async function makeTestPayment(
 
   // Check if payment was successful.
 
-  orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
-    orderId: orderResp.order_id,
-    instance,
-  }, auth);
+  orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
+    merchant,
+    {
+      orderId: orderResp.order_id,
+      instance,
+    },
+    auth,
+  );
 
   t.assertTrue(orderStatus.order_status === "paid");
 }
diff --git 
a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-fakebank.ts
similarity index 50%
copy from 
packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts
copy to 
packages/taler-wallet-cli/src/integrationtests/test-withdrawal-fakebank.ts
index 61361807..bfe29b32 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-fakebank.ts
@@ -17,48 +17,68 @@
 /**
  * Imports.
  */
-import { GlobalTestState, BankApi } from "./harness";
+import {
+  GlobalTestState,
+  BankApi,
+  WalletCli,
+  setupDb,
+  ExchangeService,
+  FakeBankService,
+} from "./harness";
 import { createSimpleTestkudosEnvironment } from "./helpers";
-import { CoreApiResponse } from "@gnu-taler/taler-util";
-import { codecForBalancesResponse } from "@gnu-taler/taler-util";
 import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "./denomStructures.js";
+import { URL } from "@gnu-taler/taler-util";
 
 /**
  * Run test for basic, bank-integrated withdrawal.
  */
-export async function runTestWithdrawalManualTest(t: GlobalTestState) {
+export async function runTestWithdrawalFakebankTest(t: GlobalTestState) {
   // Set up test environment
 
-  const {
-    wallet,
-    bank,
-    exchange,
-    exchangeBankAccount,
-  } = await createSimpleTestkudosEnvironment(t);
+  const db = await setupDb(t);
 
-  // Create a withdrawal operation
-
-  const user = await BankApi.createRandomBankUser(bank);
+  const bank = await FakeBankService.create(t, {
+    currency: "TESTKUDOS",
+    httpPort: 8082,
+  });
 
-  let wresp: CoreApiResponse;
+  const exchange = ExchangeService.create(t, {
+    name: "testexchange-1",
+    currency: "TESTKUDOS",
+    httpPort: 8081,
+    database: db.connStr,
+  });
 
-  await wallet.client.call(WalletApiOperation.AddExchange, {
-    exchangeBaseUrl: exchange.baseUrl,
+  exchange.addBankAccount("1", {
+    accountName: "exchange",
+    accountPassword: "x",
+    wireGatewayApiBaseUrl: new URL("/exchange/", bank.baseUrl).href,
+    accountPaytoUri: "payto://x-taler-bank/localhost/exchange",
   });
 
+  await bank.start();
+
+  await bank.pingUntilAvailable();
+
+  const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => 
x("TESTKUDOS"));
+  exchange.addCoinConfigList(coinConfig);
 
-  const wres = await 
wallet.client.call(WalletApiOperation.AcceptManualWithdrawal, {
+  await exchange.start();
+  await exchange.pingUntilAvailable();
+
+  console.log("setup done!");
+
+  const wallet = new WalletCli(t);
+
+  await wallet.client.call(WalletApiOperation.AddExchange, {
     exchangeBaseUrl: exchange.baseUrl,
-    amount: "TESTKUDOS:10",
   });
 
-  const reservePub: string = wres.reservePub;
-
-  await BankApi.adminAddIncoming(bank, {
-    exchangeBankAccount,
+  await wallet.client.call(WalletApiOperation.WithdrawFakebank, {
+    exchange: exchange.baseUrl,
     amount: "TESTKUDOS:10",
-    debitAccountPayto: user.accountPaytoUri,
-    reservePub: reservePub,
+    bank: bank.baseUrl,
   });
 
   await exchange.runWirewatchOnce();
@@ -73,4 +93,4 @@ export async function runTestWithdrawalManualTest(t: 
GlobalTestState) {
   await t.shutdown();
 }
 
-runTestWithdrawalManualTest.suites = ["wallet"];
+runTestWithdrawalFakebankTest.suites = ["wallet"];
diff --git 
a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts
index 61361807..fe8fd3c5 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts
@@ -19,8 +19,6 @@
  */
 import { GlobalTestState, BankApi } from "./harness";
 import { createSimpleTestkudosEnvironment } from "./helpers";
-import { CoreApiResponse } from "@gnu-taler/taler-util";
-import { codecForBalancesResponse } from "@gnu-taler/taler-util";
 import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
 
 /**
@@ -40,8 +38,6 @@ export async function runTestWithdrawalManualTest(t: 
GlobalTestState) {
 
   const user = await BankApi.createRandomBankUser(bank);
 
-  let wresp: CoreApiResponse;
-
   await wallet.client.call(WalletApiOperation.AddExchange, {
     exchangeBaseUrl: exchange.baseUrl,
   });
diff --git a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts 
b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
index 720dd8b8..bcb0dd27 100644
--- a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
@@ -87,6 +87,7 @@ import { runPaymentZeroTest } from "./test-payment-zero.js";
 import { runMerchantSpecPublicOrdersTest } from 
"./test-merchant-spec-public-orders.js";
 import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js";
 import { runDenomUnofferedTest } from "./test-denom-unoffered.js";
+import { runTestWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js";
 
 /**
  * Test runner.
@@ -154,6 +155,7 @@ const allTests: TestMainFunction[] = [
   runRefundTest,
   runRevocationTest,
   runTestWithdrawalManualTest,
+  runTestWithdrawalFakebankTest,
   runTimetravelAutorefreshTest,
   runTimetravelWithdrawTest,
   runTippingTest,
@@ -340,7 +342,7 @@ export async function runTests(spec: TestRunSpec) {
 
     try {
       result = await token.racePromise(resultPromise);
-    } catch (e) {
+    } catch (e: any) {
       console.error(`test ${testName} timed out`);
       if (token.isCancelled) {
         result = {
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts 
b/packages/taler-wallet-core/src/wallet-api-types.ts
index 75121ed3..c5bf2c8c 100644
--- a/packages/taler-wallet-core/src/wallet-api-types.ts
+++ b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -63,10 +63,14 @@ import {
   TransactionsResponse,
   WalletBackupContentV1,
   WalletCurrencyInfo,
+  WithdrawFakebankRequest,
   WithdrawTestBalanceRequest,
   WithdrawUriInfoResponse,
 } from "@gnu-taler/taler-util";
-import { AddBackupProviderRequest, BackupInfo } from 
"./operations/backup/index.js";
+import {
+  AddBackupProviderRequest,
+  BackupInfo,
+} from "./operations/backup/index.js";
 import { PendingOperationsResponse } from "./pending-types.js";
 
 export enum WalletApiOperation {
@@ -110,9 +114,14 @@ export enum WalletApiOperation {
   CreateDepositGroup = "createDepositGroup",
   SetWalletDeviceId = "setWalletDeviceId",
   ExportBackupPlain = "exportBackupPlain",
+  WithdrawFakebank = "withdrawFakebank",
 }
 
 export type WalletOperations = {
+  [WalletApiOperation.WithdrawFakebank]: {
+    request: WithdrawFakebankRequest;
+    response: {};
+  };
   [WalletApiOperation.PreparePayForUri]: {
     request: PreparePayRequest;
     response: PreparePayResult;
@@ -256,7 +265,7 @@ export type WalletOperations = {
   [WalletApiOperation.TestPay]: {
     request: TestPayArgs;
     response: {};
-  }
+  };
 };
 
 export type RequestType<
diff --git a/packages/taler-wallet-core/src/wallet.ts 
b/packages/taler-wallet-core/src/wallet.ts
index 253a69df..32e3945e 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -38,6 +38,9 @@ import {
   Timestamp,
   timestampMin,
   WalletNotification,
+  codecForWithdrawFakebankRequest,
+  URL,
+  parsePaytoUri,
 } from "@gnu-taler/taler-util";
 import {
   addBackupProvider,
@@ -173,7 +176,10 @@ import {
   openPromise,
 } from "./util/promiseUtils.js";
 import { DbAccess } from "./util/query.js";
-import { HttpRequestLibrary } from "./util/http.js";
+import {
+  HttpRequestLibrary,
+  readSuccessResponseJsonOrThrow,
+} from "./util/http.js";
 
 const builtinAuditors: AuditorTrustRecord[] = [
   {
@@ -217,7 +223,12 @@ async function processOnePendingOperation(
   logger.trace(`running pending ${JSON.stringify(pending, undefined, 2)}`);
   switch (pending.type) {
     case PendingTaskType.ExchangeUpdate:
-      await updateExchangeFromUrl(ws, pending.exchangeBaseUrl, undefined, 
forceNow);
+      await updateExchangeFromUrl(
+        ws,
+        pending.exchangeBaseUrl,
+        undefined,
+        forceNow,
+      );
       break;
     case PendingTaskType.Refresh:
       await processRefreshGroup(ws, pending.refreshGroupId, forceNow);
@@ -418,7 +429,7 @@ async function fillDefaults(ws: InternalWalletState): 
Promise<void> {
 }
 
 /**
- * Create a reserve, but do not flag it as confirmed yet.
+ * Create a reserve for a manual withdrawal.
  *
  * Adds the corresponding exchange as a trusted exchange if it is neither
  * audited nor trusted already.
@@ -462,7 +473,11 @@ async function getExchangeTos(
   const content = exchangeDetails.termsOfServiceText;
   const currentEtag = exchangeDetails.termsOfServiceLastEtag;
   const contentType = exchangeDetails.termsOfServiceContentType;
-  if (content === undefined || currentEtag === undefined || contentType === 
undefined) {
+  if (
+    content === undefined ||
+    currentEtag === undefined ||
+    contentType === undefined
+  ) {
     throw Error("exchange is in invalid state");
   }
   return {
@@ -688,7 +703,12 @@ async function dispatchRequestInternal(
     }
     case "addExchange": {
       const req = codecForAddExchangeRequest().decode(payload);
-      await updateExchangeFromUrl(ws, req.exchangeBaseUrl, undefined, 
req.forceUpdate);
+      await updateExchangeFromUrl(
+        ws,
+        req.exchangeBaseUrl,
+        undefined,
+        req.forceUpdate,
+      );
       return {};
     }
     case "listExchanges": {
@@ -700,7 +720,11 @@ async function dispatchRequestInternal(
     }
     case "getExchangeWithdrawalInfo": {
       const req = codecForGetExchangeWithdrawalInfo().decode(payload);
-      return await getExchangeWithdrawalInfo(ws, req.exchangeBaseUrl, 
req.amount);
+      return await getExchangeWithdrawalInfo(
+        ws,
+        req.exchangeBaseUrl,
+        req.amount,
+      );
     }
     case "acceptManualWithdrawal": {
       const req = codecForAcceptManualWithdrawalRequet().decode(payload);
@@ -748,7 +772,7 @@ async function dispatchRequestInternal(
     }
     case "getExchangeTos": {
       const req = codecForGetExchangeTosRequest().decode(payload);
-      return getExchangeTos(ws, req.exchangeBaseUrl , req.acceptedFormat);
+      return getExchangeTos(ws, req.exchangeBaseUrl, req.acceptedFormat);
     }
     case "retryPendingNow": {
       await runPending(ws, true);
@@ -889,6 +913,35 @@ async function dispatchRequestInternal(
           };
         });
     }
+    case "withdrawFakebank": {
+      const req = codecForWithdrawFakebankRequest().decode(payload);
+      const amount = Amounts.parseOrThrow(req.amount);
+      const details = await getWithdrawalDetailsForAmount(
+        ws,
+        req.exchange,
+        amount,
+      );
+      const wres = await acceptManualWithdrawal(ws, req.exchange, amount);
+      const paytoUri = details.paytoUris[0];
+      const pt = parsePaytoUri(paytoUri);
+      if (!pt) {
+        throw Error("failed to parse payto URI");
+      }
+      const components = pt.targetPath.split("/");
+      const creditorAcct = components[components.length - 1];
+      logger.info(`making testbank transfer to '${creditorAcct}''`)
+      const fbReq = await ws.http.postJson(
+        new URL(`${creditorAcct}/admin/add-incoming`, req.bank).href,
+        {
+          amount: Amounts.stringify(amount),
+          reserve_pub: wres.reservePub,
+          debit_account: "payto://x-taler-bank/localhost/testdebtor",
+        },
+      );
+      const fbResp = await readSuccessResponseJsonOrThrow(fbReq, 
codecForAny());
+      logger.info(`started fakebank withdrawal: ${j2s(fbResp)}`);
+      return {};
+    }
   }
   throw OperationFailedError.fromCode(
     TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,
@@ -916,7 +969,7 @@ export async function handleCoreApiRequest(
       id,
       result,
     };
-  } catch (e) {
+  } catch (e: any) {
     if (
       e instanceof OperationFailedError ||
       e instanceof OperationFailedAndReportedError
@@ -928,6 +981,10 @@ export async function handleCoreApiRequest(
         error: e.operationError,
       };
     } else {
+      try {
+        logger.error("Caught unexpected exception:");
+        logger.error(e.stack);
+      } catch (e) {}
       return {
         type: "error",
         operation,

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]