Skip to content

Commit d6da354

Browse files
committed
(feat) (openai/gpt-5.5, reviewed F, tested T) add b20 wrap commands
1 parent 0ee83bf commit d6da354

3 files changed

Lines changed: 311 additions & 0 deletions

File tree

packages/cli/src/b20/b20-ops.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { Abi, Account, Address, Hash } from "viem";
2+
import { isAddress, parseAbi, parseEther } from "viem";
3+
4+
import FCFWrapperAbi from "../abi/FCFWrapper.json" with { type: "json" };
5+
6+
const fcfWrapperAbi = FCFWrapperAbi as Abi;
7+
const erc20Abi = parseAbi([
8+
"function allowance(address owner, address spender) view returns (uint256)",
9+
"function approve(address spender, uint256 amount) returns (bool)",
10+
]);
11+
12+
export type TransactionReceipt = {
13+
readonly status: "success" | "reverted";
14+
};
15+
16+
export type PublicClientLike = {
17+
readContract(args: {
18+
readonly address: Address;
19+
readonly abi: Abi;
20+
readonly functionName: string;
21+
readonly args?: readonly unknown[];
22+
}): Promise<unknown>;
23+
waitForTransactionReceipt(args: { readonly hash: Hash }): Promise<TransactionReceipt>;
24+
};
25+
26+
export type WalletClientLike = {
27+
writeContract(args: {
28+
readonly address: Address;
29+
readonly abi: Abi;
30+
readonly functionName: string;
31+
readonly args: readonly unknown[];
32+
readonly account: Account;
33+
}): Promise<Hash>;
34+
};
35+
36+
export type B20OperationParams = {
37+
readonly amount: string;
38+
readonly wrapperAddress: Address;
39+
readonly account: Account;
40+
readonly publicClient: PublicClientLike;
41+
readonly walletClient: WalletClientLike;
42+
};
43+
44+
export type ApprovalResult =
45+
| { readonly approved: false }
46+
| { readonly approved: true; readonly hash: Hash; readonly receipt: TransactionReceipt };
47+
48+
export type B20WrapResult = {
49+
readonly amount: bigint;
50+
readonly fcfToken: Address;
51+
readonly wrappedB20: Address;
52+
readonly approval: ApprovalResult;
53+
readonly hash: Hash;
54+
readonly receipt: TransactionReceipt;
55+
};
56+
57+
export type B20UnwrapResult = {
58+
readonly amount: bigint;
59+
readonly wrappedB20: Address;
60+
readonly approval: ApprovalResult;
61+
readonly hash: Hash;
62+
readonly receipt: TransactionReceipt;
63+
};
64+
65+
export async function wrapB20(params: B20OperationParams): Promise<B20WrapResult> {
66+
const amount = parsePositiveAmount(params.amount);
67+
const fcfToken = await readWrapperAddress(params.publicClient, params.wrapperAddress, "fcfToken");
68+
const wrappedB20 = await readWrapperAddress(params.publicClient, params.wrapperAddress, "wrappedB20");
69+
const approval = await approveIfNeeded({
70+
...params,
71+
token: fcfToken,
72+
spender: params.wrapperAddress,
73+
amount,
74+
});
75+
const hash = await params.walletClient.writeContract({
76+
address: params.wrapperAddress,
77+
abi: fcfWrapperAbi,
78+
functionName: "deposit",
79+
args: [amount],
80+
account: params.account,
81+
});
82+
const receipt = await params.publicClient.waitForTransactionReceipt({ hash });
83+
84+
return { amount, fcfToken, wrappedB20, approval, hash, receipt };
85+
}
86+
87+
export async function unwrapB20(params: B20OperationParams): Promise<B20UnwrapResult> {
88+
const amount = parsePositiveAmount(params.amount);
89+
const wrappedB20 = await readWrapperAddress(params.publicClient, params.wrapperAddress, "wrappedB20");
90+
const approval = await approveIfNeeded({
91+
...params,
92+
token: wrappedB20,
93+
spender: params.wrapperAddress,
94+
amount,
95+
});
96+
const hash = await params.walletClient.writeContract({
97+
address: params.wrapperAddress,
98+
abi: fcfWrapperAbi,
99+
functionName: "withdraw",
100+
args: [amount],
101+
account: params.account,
102+
});
103+
const receipt = await params.publicClient.waitForTransactionReceipt({ hash });
104+
105+
return { amount, wrappedB20, approval, hash, receipt };
106+
}
107+
108+
function parsePositiveAmount(amount: string): bigint {
109+
let parsed: bigint;
110+
try {
111+
parsed = parseEther(amount);
112+
} catch {
113+
throw new Error(`invalid amount: ${amount}`);
114+
}
115+
if (parsed <= 0n) throw new Error("amount must be greater than zero");
116+
return parsed;
117+
}
118+
119+
async function readWrapperAddress(
120+
publicClient: PublicClientLike,
121+
wrapperAddress: Address,
122+
functionName: "fcfToken" | "wrappedB20",
123+
): Promise<Address> {
124+
const value = await publicClient.readContract({
125+
address: wrapperAddress,
126+
abi: fcfWrapperAbi,
127+
functionName,
128+
});
129+
if (typeof value !== "string" || !isAddress(value)) throw new Error(`invalid ${functionName} address`);
130+
return value;
131+
}
132+
133+
async function approveIfNeeded(params: Omit<B20OperationParams, "amount"> & {
134+
readonly token: Address;
135+
readonly spender: Address;
136+
readonly amount: bigint;
137+
}): Promise<ApprovalResult> {
138+
const allowance = await params.publicClient.readContract({
139+
address: params.token,
140+
abi: erc20Abi,
141+
functionName: "allowance",
142+
args: [params.account.address, params.spender],
143+
});
144+
if (typeof allowance !== "bigint") throw new Error("invalid ERC-20 allowance result");
145+
if (allowance >= params.amount) return { approved: false };
146+
147+
const hash = await params.walletClient.writeContract({
148+
address: params.token,
149+
abi: erc20Abi,
150+
functionName: "approve",
151+
args: [params.spender, params.amount],
152+
account: params.account,
153+
});
154+
const receipt = await params.publicClient.waitForTransactionReceipt({ hash });
155+
return { approved: true, hash, receipt };
156+
}

packages/cli/src/index.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { getLocalWallet, walletFilePath } from "./wallet/store.js";
2323
import { wallet } from "viem/tempo/actions";
2424
import { getConfig, type Config } from "@/utils/config.js";
2525
import { get } from "node:http";
26+
import { unwrapB20, wrapB20, type ApprovalResult } from "./b20/b20-ops.js";
2627

2728
const COMMAND_NAME = "fcf";
2829
const COMMAND_DESCRIPTION = "FCF CLI tool.";
@@ -62,6 +63,7 @@ function buildProgram(): Command {
6263
addKeysSyncCommand(program);
6364
addListCommand(program);
6465
addWalletCommand(program);
66+
addB20Command(program);
6567
const githubCommand = program.command("github");
6668
addGithubWhoamiCommand(githubCommand);
6769
addGithubSecretsCommand(githubCommand);
@@ -73,6 +75,40 @@ function buildProgram(): Command {
7375
return program;
7476
}
7577

78+
function addB20Command(program: Command): void {
79+
const b20Command = program.command("b20");
80+
81+
b20Command
82+
.command("wrap")
83+
.argument("<amount>", "fcf amount to wrap")
84+
.option("--wrapper <addr>", "deployed FCFWrapper address")
85+
.action(async (amount: string, opts: { wrapper?: string }) => {
86+
const { account, publicClient, walletClient } = clients();
87+
const wrapperAddress = resolveFcfWrapperAddress(opts.wrapper);
88+
try {
89+
const result = await wrapB20({ amount, wrapperAddress, account, publicClient, walletClient });
90+
console.log(
91+
`wrapped: amount=${result.amount} fcf=${result.fcfToken} wrappedB20=${result.wrappedB20} approve=${formatApproval(result.approval)} tx=${result.hash} status=${result.receipt.status}`
92+
);
93+
} catch (err) { die(err); }
94+
});
95+
96+
b20Command
97+
.command("unwrap")
98+
.argument("<amount>", "wrapped B20 amount to unwrap")
99+
.option("--wrapper <addr>", "deployed FCFWrapper address")
100+
.action(async (amount: string, opts: { wrapper?: string }) => {
101+
const { account, publicClient, walletClient } = clients();
102+
const wrapperAddress = resolveFcfWrapperAddress(opts.wrapper);
103+
try {
104+
const result = await unwrapB20({ amount, wrapperAddress, account, publicClient, walletClient });
105+
console.log(
106+
`unwrapped: amount=${result.amount} wrappedB20=${result.wrappedB20} approve=${formatApproval(result.approval)} tx=${result.hash} status=${result.receipt.status}`
107+
);
108+
} catch (err) { die(err); }
109+
});
110+
}
111+
76112
function addMarketCommand(program: Command): void {
77113
const marketCommand = program.command("market");
78114

@@ -421,6 +457,18 @@ function loadAbi(): Abi {
421457
}
422458
}
423459

460+
function resolveFcfWrapperAddress(wrapper?: string): `0x${string}` {
461+
const { fcfWrapperAddress } = getConfig();
462+
const address = wrapper ?? (typeof fcfWrapperAddress === "string" ? fcfWrapperAddress : undefined);
463+
if (!address) die(new Error("failed to get FCFWrapper contract address"));
464+
if (!isAddress(address)) die(new Error("invalid FCFWrapper contract address"));
465+
return address;
466+
}
467+
468+
function formatApproval(approval: ApprovalResult): string {
469+
return approval.approved ? `${approval.hash} status=${approval.receipt.status}` : "skipped";
470+
}
471+
424472
function die(err: any): never {
425473
let error = "unknown error";
426474
if (err instanceof Error) error = err.message;

packages/cli/test/b20-ops.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { expect, test } from "vitest";
2+
import type { Abi, Account, Address, Hash } from "viem";
3+
import { privateKeyToAccount } from "viem/accounts";
4+
5+
import { unwrapB20, wrapB20, type PublicClientLike, type WalletClientLike } from "@/b20/b20-ops.js";
6+
7+
const account = privateKeyToAccount("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
8+
const wrapperAddress = "0x1000000000000000000000000000000000000000" as Address;
9+
const fcfToken = "0x2000000000000000000000000000000000000000" as Address;
10+
const wrappedB20 = "0x3000000000000000000000000000000000000000" as Address;
11+
12+
type ReadCall = {
13+
readonly address: Address;
14+
readonly abi: Abi;
15+
readonly functionName: string;
16+
readonly args?: readonly unknown[];
17+
};
18+
19+
type WriteCall = ReadCall & {
20+
readonly account: Account;
21+
};
22+
23+
function makeClients(allowance: bigint): {
24+
readonly publicClient: PublicClientLike;
25+
readonly walletClient: WalletClientLike;
26+
readonly reads: ReadCall[];
27+
readonly writes: WriteCall[];
28+
} {
29+
const reads: ReadCall[] = [];
30+
const writes: WriteCall[] = [];
31+
32+
return {
33+
reads,
34+
writes,
35+
publicClient: {
36+
async readContract(args) {
37+
reads.push(args);
38+
if (args.functionName === "fcfToken") return fcfToken;
39+
if (args.functionName === "wrappedB20") return wrappedB20;
40+
if (args.functionName === "allowance") return allowance;
41+
throw new Error(`unexpected read: ${args.functionName}`);
42+
},
43+
async waitForTransactionReceipt() {
44+
return { status: "success" };
45+
},
46+
},
47+
walletClient: {
48+
async writeContract(args) {
49+
writes.push(args);
50+
return `0x${String(writes.length).padStart(64, "0")}` as Hash;
51+
},
52+
},
53+
};
54+
}
55+
56+
test("wrap approves fcf then deposits parsed amount", async () => {
57+
const { publicClient, walletClient, writes } = makeClients(0n);
58+
59+
const result = await wrapB20({
60+
amount: "1.5",
61+
wrapperAddress,
62+
account,
63+
publicClient,
64+
walletClient,
65+
});
66+
67+
expect(result.amount).toBe(1500000000000000000n);
68+
expect(result.fcfToken).toBe(fcfToken);
69+
expect(result.wrappedB20).toBe(wrappedB20);
70+
expect(result.approval.approved).toBe(true);
71+
expect(writes.map((write) => write.functionName)).toEqual(["approve", "deposit"]);
72+
expect(writes[0]?.address).toBe(fcfToken);
73+
expect(writes[0]?.args).toEqual([wrapperAddress, 1500000000000000000n]);
74+
expect(writes[1]?.address).toBe(wrapperAddress);
75+
expect(writes[1]?.args).toEqual([1500000000000000000n]);
76+
});
77+
78+
test("unwrap skips approval when wrapped allowance is sufficient", async () => {
79+
const { publicClient, walletClient, writes } = makeClients(2_000_000000000000000n);
80+
81+
const result = await unwrapB20({
82+
amount: "2",
83+
wrapperAddress,
84+
account,
85+
publicClient,
86+
walletClient,
87+
});
88+
89+
expect(result.amount).toBe(2000000000000000000n);
90+
expect(result.wrappedB20).toBe(wrappedB20);
91+
expect(result.approval.approved).toBe(false);
92+
expect(writes.map((write) => write.functionName)).toEqual(["withdraw"]);
93+
expect(writes[0]?.address).toBe(wrapperAddress);
94+
expect(writes[0]?.args).toEqual([2000000000000000000n]);
95+
});
96+
97+
test("rejects zero amounts", async () => {
98+
const { publicClient, walletClient } = makeClients(0n);
99+
100+
await expect(wrapB20({
101+
amount: "0",
102+
wrapperAddress,
103+
account,
104+
publicClient,
105+
walletClient,
106+
})).rejects.toThrow("amount must be greater than zero");
107+
});

0 commit comments

Comments
 (0)