Skip to content

Commit 1b50a4f

Browse files
committed
add change_threshold/remove_signer actions and tests
1 parent 21dfb7a commit 1b50a4f

File tree

8 files changed

+677
-20
lines changed

8 files changed

+677
-20
lines changed

package-lock.json

+4-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { safeApiActionProvider } from "./safeApiActionProvider";
2+
import { SafeInfoSchema } from "./schemas";
3+
import { EvmWalletProvider } from "../../wallet-providers";
4+
import SafeApiKit from "@safe-global/api-kit";
5+
6+
// Mock the Safe API Kit
7+
jest.mock("@safe-global/api-kit");
8+
9+
describe("Safe API Action Provider Input Schemas", () => {
10+
describe("Safe Info Schema", () => {
11+
it("should successfully parse valid input", () => {
12+
const validInput = {
13+
safeAddress: "0xe6b2af36b3bb8d47206a129ff11d5a2de2a63c83",
14+
};
15+
16+
const result = SafeInfoSchema.safeParse(validInput);
17+
18+
expect(result.success).toBe(true);
19+
expect(result.data).toEqual(validInput);
20+
});
21+
22+
it("should fail parsing invalid address", () => {
23+
const invalidInput = {
24+
safeAddress: "invalid-address",
25+
};
26+
const result = SafeInfoSchema.safeParse(invalidInput);
27+
28+
expect(result.success).toBe(false);
29+
});
30+
31+
it("should fail parsing empty input", () => {
32+
const emptyInput = {};
33+
const result = SafeInfoSchema.safeParse(emptyInput);
34+
35+
expect(result.success).toBe(false);
36+
});
37+
});
38+
});
39+
40+
describe("Safe API Action Provider", () => {
41+
let actionProvider: ReturnType<typeof safeApiActionProvider>;
42+
let mockWallet: jest.Mocked<EvmWalletProvider>;
43+
let mockSafeApiKit: jest.Mocked<SafeApiKit>;
44+
45+
const MOCK_SAFE_ADDRESS = "0xe6b2af36b3bb8d47206a129ff11d5a2de2a63c83";
46+
const MOCK_NETWORK = "base-sepolia";
47+
const MOCK_BALANCE = BigInt(1000000000000000000); // 1 ETH in wei
48+
49+
beforeEach(() => {
50+
// Reset all mocks before each test
51+
jest.clearAllMocks();
52+
53+
// Mock SafeApiKit methods first
54+
mockSafeApiKit = {
55+
getSafeInfo: jest.fn(),
56+
getPendingTransactions: jest.fn(),
57+
} as unknown as jest.Mocked<SafeApiKit>;
58+
59+
// Set up the mock implementation before creating actionProvider
60+
(SafeApiKit as jest.Mock).mockImplementation(() => mockSafeApiKit);
61+
62+
actionProvider = safeApiActionProvider({ networkId: MOCK_NETWORK });
63+
64+
// Mock wallet provider
65+
mockWallet = {
66+
getPublicClient: jest.fn().mockReturnValue({
67+
getBalance: jest.fn().mockResolvedValue(MOCK_BALANCE),
68+
}),
69+
} as unknown as jest.Mocked<EvmWalletProvider>;
70+
});
71+
72+
describe("safeInfo", () => {
73+
it("should successfully get Safe info", async () => {
74+
const mockSafeInfo = {
75+
address: MOCK_SAFE_ADDRESS,
76+
owners: ["0x123", "0x456"],
77+
threshold: 2,
78+
modules: ["0x789"],
79+
nonce: "1",
80+
singleton: "0x123",
81+
fallbackHandler: "0x456",
82+
guard: "0x789",
83+
version: "1.0.0"
84+
};
85+
86+
const mockPendingTransactions = {
87+
results: [
88+
{
89+
safeTxHash: "0xabc",
90+
isExecuted: false,
91+
confirmationsRequired: 2,
92+
confirmations: [
93+
{ owner: "0x123" },
94+
{ owner: "0x456" },
95+
],
96+
},
97+
],
98+
count: 1,
99+
};
100+
101+
mockSafeApiKit.getSafeInfo.mockResolvedValue(mockSafeInfo);
102+
mockSafeApiKit.getPendingTransactions.mockResolvedValue(mockPendingTransactions as any);
103+
104+
const args = {
105+
safeAddress: MOCK_SAFE_ADDRESS,
106+
};
107+
108+
const response = await actionProvider.safeInfo(mockWallet, args);
109+
110+
// Verify response contains expected information
111+
expect(response).toContain(`Safe at address: ${MOCK_SAFE_ADDRESS}`);
112+
expect(response).toContain("2 owners: 0x123, 0x456");
113+
expect(response).toContain("Threshold: 2");
114+
expect(response).toContain("Nonce: 1");
115+
expect(response).toContain("Modules: 0x789");
116+
expect(response).toContain("Balance: 1 ETH");
117+
expect(response).toContain("Pending transactions: 1");
118+
expect(response).toContain("Transaction 0xabc (2/2 confirmations, confirmed by: 0x123, 0x456)");
119+
});
120+
121+
it("should handle errors when getting Safe info", async () => {
122+
const error = new Error("Failed to get Safe info");
123+
mockSafeApiKit.getSafeInfo.mockRejectedValue(error);
124+
125+
const args = {
126+
safeAddress: MOCK_SAFE_ADDRESS,
127+
};
128+
129+
const response = await actionProvider.safeInfo(mockWallet, args);
130+
expect(response).toBe(`Safe info: Error connecting to Safe: ${error.message}`);
131+
});
132+
});
133+
134+
describe("supportsNetwork", () => {
135+
it("should return true for EVM networks", () => {
136+
const result = actionProvider.supportsNetwork({ protocolFamily: "evm" } as any);
137+
expect(result).toBe(true);
138+
});
139+
140+
it("should return false for non-EVM networks", () => {
141+
const result = actionProvider.supportsNetwork({ protocolFamily: "solana" } as any);
142+
expect(result).toBe(false);
143+
});
144+
});
145+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { SafeWalletActionProvider } from "./safeWalletActionProvider";
2+
import { SafeWalletProvider } from "../../wallet-providers";
3+
import { AddSignerSchema } from "./schemas";
4+
import Safe from "@safe-global/protocol-kit";
5+
6+
// Mock Safe SDK modules
7+
jest.mock("@safe-global/protocol-kit");
8+
jest.mock("@safe-global/api-kit");
9+
10+
describe("SafeWalletActionProvider", () => {
11+
let actionProvider: SafeWalletActionProvider;
12+
let mockWallet: jest.Mocked<SafeWalletProvider>;
13+
const MOCK_SAFE_ADDRESS = "0x1234567890123456789012345678901234567890";
14+
const MOCK_NEW_SIGNER = "0x9876543210987654321098765432109876543210";
15+
const MOCK_TRANSACTION_HASH = "0xtxhash123";
16+
17+
beforeEach(() => {
18+
// Reset mocks
19+
jest.clearAllMocks();
20+
21+
actionProvider = new SafeWalletActionProvider();
22+
23+
// Mock SafeWalletProvider
24+
mockWallet = {
25+
getAddress: jest.fn().mockReturnValue(MOCK_SAFE_ADDRESS),
26+
getNetwork: jest.fn().mockReturnValue({ networkId: "base-sepolia" }),
27+
getSafeClient: jest.fn(),
28+
waitForInitialization: jest.fn().mockResolvedValue(undefined),
29+
addOwnerWithThreshold: jest.fn().mockResolvedValue(
30+
`Successfully proposed adding signer ${MOCK_NEW_SIGNER} to Safe ${MOCK_SAFE_ADDRESS}. Safe transaction hash: ${MOCK_TRANSACTION_HASH}. The other signers will need to confirm the transaction before it can be executed.`
31+
),
32+
removeOwnerWithThreshold: jest.fn(),
33+
changeThreshold: jest.fn(),
34+
} as unknown as jest.Mocked<SafeWalletProvider>;
35+
36+
// Mock Safe client methods
37+
const mockSafeClient = {
38+
getOwners: jest.fn().mockResolvedValue(["0xowner1", "0xowner2"]),
39+
getThreshold: jest.fn().mockResolvedValue(2),
40+
createTransaction: jest.fn().mockResolvedValue({
41+
data: { safeTxHash: MOCK_TRANSACTION_HASH },
42+
}),
43+
getPendingTransactions: jest.fn().mockResolvedValue({
44+
results: [],
45+
}),
46+
} as unknown as Safe;
47+
48+
mockWallet.getSafeClient.mockReturnValue(mockSafeClient);
49+
});
50+
51+
describe("Input Schema Validation", () => {
52+
it("should validate AddSignerSchema with valid input", () => {
53+
const validInput = {
54+
safeAddress: MOCK_SAFE_ADDRESS,
55+
newSigner: MOCK_NEW_SIGNER,
56+
};
57+
58+
const result = AddSignerSchema.safeParse(validInput);
59+
expect(result.success).toBe(true);
60+
});
61+
62+
it("should reject AddSignerSchema with invalid address", () => {
63+
const invalidInput = {
64+
safeAddress: "not-an-address",
65+
newSigner: "not-an-address",
66+
};
67+
68+
const result = AddSignerSchema.safeParse(invalidInput);
69+
expect(result.success).toBe(false);
70+
});
71+
});
72+
73+
describe("addSigner", () => {
74+
it("should successfully add a new signer", async () => {
75+
const args = {
76+
safeAddress: MOCK_SAFE_ADDRESS,
77+
newSigner: MOCK_NEW_SIGNER,
78+
};
79+
80+
const response = await actionProvider.addSigner(mockWallet, args);
81+
82+
expect(response).toContain(`Successfully proposed adding signer ${MOCK_NEW_SIGNER}`);
83+
expect(response).toContain(`Safe transaction hash: ${MOCK_TRANSACTION_HASH}`);
84+
});
85+
86+
it("should fail when adding an existing owner", async () => {
87+
const args = {
88+
safeAddress: MOCK_SAFE_ADDRESS,
89+
newSigner: "0xowner1", // Using an address that's already an owner
90+
};
91+
92+
const error = new Error("Address is already an owner of this Safe");
93+
mockWallet.addOwnerWithThreshold.mockRejectedValue(error);
94+
95+
await expect(actionProvider.addSigner(mockWallet, args)).rejects.toThrow(
96+
"Failed to add signer: Address is already an owner of this Safe"
97+
);
98+
});
99+
100+
it("should fail when threshold is less than 1", async () => {
101+
const args = {
102+
safeAddress: MOCK_SAFE_ADDRESS,
103+
newSigner: MOCK_NEW_SIGNER,
104+
newThreshold: 0,
105+
};
106+
107+
const error = new Error("Threshold must be at least 1");
108+
mockWallet.addOwnerWithThreshold.mockRejectedValue(error);
109+
110+
await expect(actionProvider.addSigner(mockWallet, args)).rejects.toThrow(
111+
"Failed to add signer: Threshold must be at least 1"
112+
);
113+
});
114+
115+
it("should fail when threshold is greater than owner count", async () => {
116+
const args = {
117+
safeAddress: MOCK_SAFE_ADDRESS,
118+
newSigner: MOCK_NEW_SIGNER,
119+
newThreshold: 4, // Would be 3 owners after adding new signer
120+
};
121+
122+
const error = new Error("Invalid threshold: 4 cannot be greater than number of owners (3)");
123+
mockWallet.addOwnerWithThreshold.mockRejectedValue(error);
124+
125+
await expect(actionProvider.addSigner(mockWallet, args)).rejects.toThrow(
126+
"Failed to add signer: Invalid threshold: 4 cannot be greater than number of owners (3)"
127+
);
128+
});
129+
});
130+
131+
describe("removeSigner", () => {
132+
it("should successfully remove a signer", async () => {
133+
const args = {
134+
safeAddress: MOCK_SAFE_ADDRESS,
135+
signerToRemove: "0xowner2",
136+
newThreshold: 1,
137+
};
138+
139+
mockWallet.removeOwnerWithThreshold = jest.fn().mockResolvedValue(
140+
`Successfully proposed removing signer ${args.signerToRemove} from Safe ${MOCK_SAFE_ADDRESS}. Safe transaction hash: ${MOCK_TRANSACTION_HASH}. The other signers will need to confirm the transaction before it can be executed.`
141+
);
142+
143+
const response = await actionProvider.removeSigner(mockWallet, args);
144+
145+
expect(response).toContain(`Successfully proposed removing signer ${args.signerToRemove}`);
146+
expect(response).toContain(`Safe transaction hash: ${MOCK_TRANSACTION_HASH}`);
147+
});
148+
149+
it("should fail when removing non-existent owner", async () => {
150+
const args = {
151+
safeAddress: MOCK_SAFE_ADDRESS,
152+
signerToRemove: MOCK_NEW_SIGNER,
153+
newThreshold: 1,
154+
};
155+
156+
const error = new Error("Address is not an owner of this Safe");
157+
mockWallet.removeOwnerWithThreshold = jest.fn().mockRejectedValue(error);
158+
159+
await expect(actionProvider.removeSigner(mockWallet, args)).rejects.toThrow(
160+
"Address is not an owner of this Safe"
161+
);
162+
});
163+
});
164+
165+
describe("changeThreshold", () => {
166+
it("should successfully change threshold", async () => {
167+
const args = {
168+
safeAddress: MOCK_SAFE_ADDRESS,
169+
newThreshold: 2,
170+
};
171+
172+
mockWallet.changeThreshold = jest.fn().mockResolvedValue(
173+
`Successfully proposed changing threshold to ${args.newThreshold} for Safe ${MOCK_SAFE_ADDRESS}. Safe transaction hash: ${MOCK_TRANSACTION_HASH}. The other signers will need to confirm the transaction before it can be executed.`
174+
);
175+
176+
const response = await actionProvider.changeThreshold(mockWallet, args);
177+
178+
expect(response).toContain(`Successfully proposed changing threshold to ${args.newThreshold}`);
179+
expect(response).toContain(`Safe transaction hash: ${MOCK_TRANSACTION_HASH}`);
180+
});
181+
182+
it("should fail when threshold is invalid", async () => {
183+
const args = {
184+
safeAddress: MOCK_SAFE_ADDRESS,
185+
newThreshold: 3,
186+
};
187+
188+
const error = new Error("Threshold cannot be greater than owners length");
189+
mockWallet.changeThreshold = jest.fn().mockRejectedValue(error);
190+
191+
await expect(actionProvider.changeThreshold(mockWallet, args)).rejects.toThrow(
192+
"Threshold cannot be greater than owners length"
193+
);
194+
});
195+
});
196+
197+
describe("supportsNetwork", () => {
198+
it("should return true for EVM networks", () => {
199+
const evmNetwork = { protocolFamily: "evm", networkId: "base-sepolia", chainId: "1" };
200+
expect(actionProvider.supportsNetwork(evmNetwork)).toBe(true);
201+
});
202+
203+
it("should return false for non-EVM networks", () => {
204+
const nonEvmNetwork = { protocolFamily: "svm", networkId: "solana", chainId: "1" };
205+
expect(actionProvider.supportsNetwork(nonEvmNetwork)).toBe(false);
206+
});
207+
});
208+
});

0 commit comments

Comments
 (0)