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
33 changes: 33 additions & 0 deletions src/services/stellar/invoice-token-contract.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Contract, Address, nativeToScVal, xdr } from "stellar-sdk";

export class InvoiceTokenContractService {
private readonly contract: Contract;
readonly contractId: string;

constructor(contractId: string) {
if (!contractId) {
throw new Error("contractId is required.");
}
this.contractId = contractId;
this.contract = new Contract(contractId);
}

/**
* Build the Soroban contract invocation operation for minting SEP-41 invoice tokens.
*
* @param recipientAddress Stellar address receiving the minted tokens
* @param tokenAmount Token amount formatted as i128 (bigint, number, or string)
* @returns xdr.Operation configured for host function contract invocation
*/
public buildMintTx(
recipientAddress: string,
tokenAmount: bigint | number | string,
): xdr.Operation {
const toScVal = new Address(recipientAddress).toScVal();
const amountBigInt =
typeof tokenAmount === "bigint" ? tokenAmount : BigInt(tokenAmount);
const amountScVal = nativeToScVal(amountBigInt, { type: "i128" });

return this.contract.call("mint", toScVal, amountScVal);
}
}
124 changes: 124 additions & 0 deletions tests/unit/services/stellar/invoice-token-tx.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Address, nativeToScVal, scValToNative, xdr } from "stellar-sdk";
import { InvoiceTokenContractService } from "../../../../src/services/stellar/invoice-token-contract.service";

describe("InvoiceTokenContractService - buildMintTx", () => {
const TOKEN_CONTRACT_ID = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";
const TEST_RECIPIENT = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI";

let service: InvoiceTokenContractService;

beforeEach(() => {
service = new InvoiceTokenContractService(TOKEN_CONTRACT_ID);
});

it("should initialize with correct contract ID", () => {
expect(service.contractId).toBe(TOKEN_CONTRACT_ID);
});

it("should throw an error when contractId is empty", () => {
expect(() => new InvoiceTokenContractService("")).toThrow(
"contractId is required.",
);
});

it("should construct a valid InvokeHostFunction operation", () => {
const tokenAmount = 50_000_000n;
const op = service.buildMintTx(TEST_RECIPIENT, tokenAmount);

expect(op).toBeDefined();
const opBody = op.body();
expect(opBody.switch().name).toBe("invokeHostFunction");
});

it("should target the exact configured TOKEN_CONTRACT_ID", () => {
const tokenAmount = 100_000n;
const op = service.buildMintTx(TEST_RECIPIENT, tokenAmount);

const invokeHostFunctionOp = op.body().invokeHostFunctionOp();
const hostFunction = invokeHostFunctionOp.hostFunction();

expect(hostFunction.switch().name).toBe(
"hostFunctionTypeHostFunctionTypeInvokeContract",
);

const invokeContractArgs = hostFunction.invokeContract();
const contractAddressScVal = invokeContractArgs.contractAddress();
const contractAddress = Address.fromScVal(contractAddressScVal).toString();

expect(contractAddress).toBe(TOKEN_CONTRACT_ID);
});

it("should serialize the mint function name and ScVal arguments properly", () => {
const tokenAmount = 750_000n;
const op = service.buildMintTx(TEST_RECIPIENT, tokenAmount);

const invokeContractArgs = op
.body()
.invokeHostFunctionOp()
.hostFunction()
.invokeContract();

// Verify function name
const functionName = invokeContractArgs.functionName().toString();
expect(functionName).toBe("mint");

// Verify args length: [recipient, amount]
const args = invokeContractArgs.args();
expect(args).toHaveLength(2);

// Verify recipient Address ScVal serialization
const recipientScVal = args[0];
expect(Address.fromScVal(recipientScVal).toString()).toBe(TEST_RECIPIENT);

// Verify token amount i128 ScVal serialization
const amountScVal = args[1];
expect(amountScVal.switch().name).toBe("scvI128");
const nativeAmount = scValToNative(amountScVal);
expect(BigInt(nativeAmount)).toBe(tokenAmount);
});

it("should accept bigint, number, and string token amounts", () => {
const amountFromBigInt = service.buildMintTx(TEST_RECIPIENT, 1_000_000n);
const amountFromNumber = service.buildMintTx(TEST_RECIPIENT, 1_000_000);
const amountFromString = service.buildMintTx(TEST_RECIPIENT, "1000000");

const getAmount = (operation: xdr.Operation) => {
const args = operation
.body()
.invokeHostFunctionOp()
.hostFunction()
.invokeContract()
.args();
return BigInt(scValToNative(args[1]));
};

expect(getAmount(amountFromBigInt)).toBe(1_000_000n);
expect(getAmount(amountFromNumber)).toBe(1_000_000n);
expect(getAmount(amountFromString)).toBe(1_000_000n);
});

it("should handle boundary amounts such as zero and large 128-bit values", () => {
const zeroAmount = 0n;
const largeAmount = 18446744073709551615000000n;

const opZero = service.buildMintTx(TEST_RECIPIENT, zeroAmount);
const opLarge = service.buildMintTx(TEST_RECIPIENT, largeAmount);

const getAmount = (operation: xdr.Operation) => {
const args = operation
.body()
.invokeHostFunctionOp()
.hostFunction()
.invokeContract()
.args();
return BigInt(scValToNative(args[1]));
};

expect(getAmount(opZero)).toBe(zeroAmount);
expect(getAmount(opLarge)).toBe(largeAmount);
});

it("should reject invalid recipient Stellar address strings", () => {
expect(() => service.buildMintTx("INVALID_STELLAR_ADDRESS", 1000n)).toThrow();
});
});
Loading