Skip to content

Commit

Permalink
feat: add craftTransaction
Browse files Browse the repository at this point in the history
Signed-off-by: Stéphane Prohaszka <[email protected]>
  • Loading branch information
sprohaszka-ledger committed Jul 17, 2024
1 parent 0659cc6 commit df617b8
Show file tree
Hide file tree
Showing 18 changed files with 590 additions and 242 deletions.
17 changes: 11 additions & 6 deletions libs/coin-framework/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,21 @@ export type Operation = {
transactionSequenceNumber: number;
};

export type Transaction<M, S> = {
mode: M;
recipient: string;
amount: bigint;
fee: bigint;
supplement: S;
};

export type Api = {
broadcast: (tx: string) => Promise<string>;
combine: (tx: string, signature: string, pubkey?: string) => string;
craftTransaction: (
craftTransaction: <M, S>(
address: string,
transaction: {
recipient: string;
amount: bigint;
fee: bigint;
},
transaction: Transaction<M, S>,
pubkey?: string,
) => Promise<string>;
estimateFees: (addr: string, amount: bigint) => Promise<bigint>;
getBalance: (address: string) => Promise<bigint>;
Expand Down
2 changes: 1 addition & 1 deletion libs/coin-modules/coin-stellar/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ module.exports = {
coverageDirectory: "coverage",
preset: "ts-jest",
testEnvironment: "node",
testPathIgnorePatterns: ["lib/", "lib-es/"],
testPathIgnorePatterns: ["lib/", "lib-es/", ".*\\.integ\\.test\\.[tj]s"],
};
1 change: 1 addition & 0 deletions libs/coin-modules/coin-stellar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"rxjs": "^7.8.1"
},
"devDependencies": {
"@faker-js/faker": "^8.4.1",
"@types/invariant": "^2.2.2",
"@types/jest": "^29.5.10",
"@types/node": "^16.11.7",
Expand Down
2 changes: 1 addition & 1 deletion libs/coin-modules/coin-stellar/src/bridge/broadcast.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { patchOperationWithHash } from "@ledgerhq/coin-framework/operation";
import type { AccountBridge, Operation, SignedOperation } from "@ledgerhq/types-live";
import { broadcastTransaction as apiBroadcast } from "../network";
import { broadcast as apiBroadcast } from "../logic";
import { Transaction } from "../types";

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import BigNumber from "bignumber.js";
import { buildTransaction } from "./buildTransaction";
import { createFixtureAccount, createFixtureTransaction } from "../types/bridge.fixture";
import { NetworkInfo } from "../types";
import coinConfig, { type StellarCoinConfig } from "../config";

describe("buildTransaction", () => {
beforeAll(() => {
coinConfig.setCoinConfig(
(): StellarCoinConfig => ({
status: { type: "active" },
explorer: {
url: "https://stellar.coin.ledger.com", //"https://horizon-testnet.stellar.org/",
fetchLmit: 100,
},
useStaticFees: true,
enableNetworkLogs: false,
}),
);
});

it("throws an error when no fees are setted in the transaction", async () => {
// Given
const account = createFixtureAccount();
const transaction = createFixtureTransaction();

// When
await expect(buildTransaction(account, transaction)).rejects.toThrow("FeeNotLoaded");
});

it("throws an error if transaction has no NetworkInfo", async () => {
// Given
const account = createFixtureAccount({
freshAddress: "GCTS5ANSL6YCXR2M4XXM5BPN34UUT3M2VUJWVYOX5EMSHZC3T7O5Z6NZ",
});
const transaction = createFixtureTransaction({ fees: BigNumber(1) });

// When
await expect(buildTransaction(account, transaction)).rejects.toThrow("stellar family");
});

it.skip("crash if transaction amount is 0", async () => {
// Given
const account = createFixtureAccount({
freshAddress: "GCTS5ANSL6YCXR2M4XXM5BPN34UUT3M2VUJWVYOX5EMSHZC3T7O5Z6NZ",
});
const transaction = createFixtureTransaction({
amount: BigNumber(0),
fees: BigNumber(1),
networkInfo: { family: "stellar" } as NetworkInfo,
});

// When
const builtTransaction = await buildTransaction(account, transaction);

// Then
expect(builtTransaction).toBeUndefined();
});

it("throws an error when recipient is an invalid address", async () => {
// Given
const account = createFixtureAccount({
freshAddress: "GCTS5ANSL6YCXR2M4XXM5BPN34UUT3M2VUJWVYOX5EMSHZC3T7O5Z6NZ",
});
const transaction = createFixtureTransaction({
amount: BigNumber(10),
fees: BigNumber(1),
networkInfo: { family: "stellar" } as NetworkInfo,
recipient: "NEW",
});

// When
await expect(buildTransaction(account, transaction)).rejects.toThrow("destination is invalid");
});

it("returns a built transaction in Stellar format", async () => {
// Given
const account = createFixtureAccount({
freshAddress: "GCTS5ANSL6YCXR2M4XXM5BPN34UUT3M2VUJWVYOX5EMSHZC3T7O5Z6NZ",
});
const transaction = createFixtureTransaction({
amount: BigNumber(10),
fees: BigNumber(1),
networkInfo: { family: "stellar" } as NetworkInfo,
});

// When
const builtTransaction = await buildTransaction(account, transaction);

// Then
expect(builtTransaction.fee).toEqual("1");
expect(builtTransaction.source).toEqual(
"GCTS5ANSL6YCXR2M4XXM5BPN34UUT3M2VUJWVYOX5EMSHZC3T7O5Z6NZ",
);
expect(builtTransaction.operations).toHaveLength(1);
const operation = builtTransaction.operations[0];
expect(operation.type).toEqual("payment");
expect((operation as any).asset.code).toEqual("XLM");
expect((operation as any).asset.issuer).toBeUndefined();
expect(builtTransaction.toXDR().slice(0, 68)).toEqual(
"AAAAAgAAAACnLoGyX7Arx0zl7s6F7d8pSe2arRNq4dfpGSPkW5/d3AAAAAECbx/3AAHJ",
);
expect(builtTransaction.toXDR().slice(70)).toEqual(
"AAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADw9kGYtpM1vsCgDoHjZOVO/sjTKLsmA51f8vdM9oaecgAAAAAAAAAAAAAACgAAAAAAAAAA",
);
});

it("returns a built transaction in Stellar format when asset used is USDC", async () => {
// Given
const account = createFixtureAccount({
freshAddress: "GCTS5ANSL6YCXR2M4XXM5BPN34UUT3M2VUJWVYOX5EMSHZC3T7O5Z6NZ",
});
const transaction = createFixtureTransaction({
amount: BigNumber(10),
fees: BigNumber(1),
networkInfo: { family: "stellar" } as NetworkInfo,
recipient: "GDRQAROTM7WYEHZ42SXUVUOHO36MLQKLFIZ5Y2JBWVQRPCJ3SQBBA3LH",
assetCode: "USDC",
assetIssuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
});

// When
const builtTransaction = await buildTransaction(account, transaction);

// Then
expect(builtTransaction.fee).toEqual("1");
expect(builtTransaction.source).toEqual(
"GCTS5ANSL6YCXR2M4XXM5BPN34UUT3M2VUJWVYOX5EMSHZC3T7O5Z6NZ",
);
expect(builtTransaction.operations).toHaveLength(1);
const operation = builtTransaction.operations[0];
expect(operation.type).toEqual("payment");
expect((operation as any).asset.code).toEqual("USDC");
expect((operation as any).asset.issuer).toEqual(
"GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
);
expect(builtTransaction.toXDR().slice(0, 68)).toEqual(
"AAAAAgAAAACnLoGyX7Arx0zl7s6F7d8pSe2arRNq4dfpGSPkW5/d3AAAAAECbx/3AAHJ",
);
});

it("returns a built transaction in Stellar format", async () => {
// Given
const account = createFixtureAccount({
freshAddress: "GCTS5ANSL6YCXR2M4XXM5BPN34UUT3M2VUJWVYOX5EMSHZC3T7O5Z6NZ",
});
const transaction = createFixtureTransaction({
amount: BigNumber(10),
fees: BigNumber(1),
networkInfo: { family: "stellar" } as NetworkInfo,
memoType: "MEMO_TEXT",
memoValue: "Hello",
});

// When
const builtTransaction = await buildTransaction(account, transaction);

// Then
expect(builtTransaction.fee).toEqual("1");
expect(builtTransaction.source).toEqual(
"GCTS5ANSL6YCXR2M4XXM5BPN34UUT3M2VUJWVYOX5EMSHZC3T7O5Z6NZ",
);
expect(builtTransaction.operations).toHaveLength(1);
expect(builtTransaction.memo.type).toEqual("text");
expect(builtTransaction.memo.value).toEqual("Hello");
const operation = builtTransaction.operations[0];
expect(operation.type).toEqual("payment");
expect((operation as any).asset.code).toEqual("XLM");
expect((operation as any).asset.issuer).toBeUndefined();
expect(builtTransaction.toXDR().slice(0, 68)).toEqual(
"AAAAAgAAAACnLoGyX7Arx0zl7s6F7d8pSe2arRNq4dfpGSPkW5/d3AAAAAECbx/3AAHJ",
);
expect(builtTransaction.toXDR().slice(70)).toEqual(
"AAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAVIZWxsbwAAAAAAAAEAAAAAAAAAAQAAAADw9kGYtpM1vsCgDoHjZOVO/sjTKLsmA51f8vdM9oaecgAAAAAAAAAAAAAACgAAAAAAAAAA",
);
});
});
99 changes: 16 additions & 83 deletions libs/coin-modules/coin-stellar/src/bridge/buildTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import invariant from "invariant";
import { Memo, Operation as StellarSdkOperation, xdr } from "@stellar/stellar-sdk";
import { AmountRequired, FeeNotLoaded, NetworkDown } from "@ledgerhq/errors";
import { FeeNotLoaded } from "@ledgerhq/errors";
import type { Account } from "@ledgerhq/types-live";
import { StellarAssetRequired, StellarMuxedAccountNotExist, type Transaction } from "../types";
import {
buildPaymentOperation,
buildCreateAccountOperation,
buildTransactionBuilder,
buildChangeTrustOperation,
loadAccount,
} from "../network";
import { getRecipientAccount, getAmountValue } from "./logic";
import type { Transaction } from "../types";
import { craftTransaction } from "../logic";

/**
* @param {Account} account
Expand All @@ -24,81 +16,22 @@ export async function buildTransaction(account: Account, transaction: Transactio
throw new FeeNotLoaded();
}

const source = await loadAccount(account.freshAddress);

if (!source) {
throw new NetworkDown();
}

invariant(networkInfo && networkInfo.family === "stellar", "stellar family");

const transactionBuilder = buildTransactionBuilder(source, fees);
let operation: xdr.Operation<StellarSdkOperation.ChangeTrust> | null = null;

if (mode === "changeTrust") {
if (!assetCode || !assetIssuer) {
throw new StellarAssetRequired("");
}

operation = buildChangeTrustOperation(assetCode, assetIssuer);
} else {
// Payment
const amount = getAmountValue(account, transaction, fees);

if (!amount) {
throw new AmountRequired();
}

const recipientAccount = await getRecipientAccount({
account,
recipient: transaction.recipient,
});

if (recipientAccount?.id) {
operation = buildPaymentOperation({
destination: recipient,
amount,
assetCode,
assetIssuer,
});
} else {
if (recipientAccount?.isMuxedAccount) {
throw new StellarMuxedAccountNotExist("");
}

operation = buildCreateAccountOperation(recipient, amount);
}
}

transactionBuilder.addOperation(operation);

let memo: Memo | null = null;

if (memoType && memoValue) {
switch (memoType) {
case "MEMO_TEXT":
memo = Memo.text(memoValue);
break;

case "MEMO_ID":
memo = Memo.id(memoValue);
break;

case "MEMO_HASH":
memo = Memo.hash(memoValue);
break;

case "MEMO_RETURN":
memo = Memo.return(memoValue);
break;
}
}

if (memo) {
transactionBuilder.addMemo(memo);
}
const { transaction: built } = await craftTransaction(
{ address: account.freshAddress },
{
mode,
recipient,
amount: BigInt(transaction.amount.toString()),
fee: BigInt(fees.toString()),
assetCode,
assetIssuer,
memoType,
memoValue,
},
);

const built = transactionBuilder.setTimeout(0).build();
return built;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { BigNumber } from "bignumber.js";
import type { AccountBridge } from "@ledgerhq/types-live";
import { findSubAccountById } from "@ledgerhq/coin-framework/account/index";
import { formatCurrencyUnit } from "@ledgerhq/coin-framework/currencies/index";
import { isAddressValid, isAccountMultiSign, isMemoValid, getRecipientAccount } from "./logic";
import { BASE_RESERVE, MIN_BALANCE } from "../network";
import { isAddressValid, isAccountMultiSign, isMemoValid } from "./logic";
import { BASE_RESERVE, MIN_BALANCE, getRecipientAccount } from "../network";
import {
StellarWrongMemoFormat,
StellarAssetRequired,
Expand Down Expand Up @@ -99,7 +99,6 @@ export const getTransactionStatus: AccountBridge<Transaction>["getTransactionSta
}

const recipientAccount = await getRecipientAccount({
account: account,
recipient: transaction.recipient,
});

Expand Down
Loading

0 comments on commit df617b8

Please sign in to comment.