This commit is contained in:
2026-03-07 14:15:48 +03:00
commit 7655b106a7
19 changed files with 12710 additions and 0 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
WALLET_MNEMONIC=""
WALLET_VERSION="V4"
WALLET_ADDRESS=""

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
node_modules
temp
build
dist
.DS_Store
package.ts
wallets/
temp/*
# VS Code
.vscode/*
.history/
*.vsix
.env
# IDEA files
.idea
# VIM
Session.vim
.vim/
# Other private editor folders
.nvim/
.emacs/
.helix/

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
build

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"printWidth": 120,
"tabWidth": 4,
"singleQuote": true,
"bracketSpacing": true,
"semi": true
}

26
README.md Normal file
View File

@@ -0,0 +1,26 @@
# SmartGift
## Project structure
- `contracts` - source code of all the smart contracts of the project and their dependencies.
- `wrappers` - wrapper classes (implementing `Contract` from ton-core) for the contracts, including any [de]serialization primitives and compilation functions.
- `tests` - tests for the contracts.
- `scripts` - scripts used by the project, mainly the deployment scripts.
## How to use
### Build
`npx blueprint build` or `yarn blueprint build`
### Test
`npx blueprint test` or `yarn blueprint test`
### Deploy or run another script
`npx blueprint run` or `yarn blueprint run`
### Add a new contract
`npx blueprint create ContractName` or `yarn blueprint create ContractName`

12
jest.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
globalSetup: './jest.setup.ts',
cache: false, // disabled caching to prevent old Tact files from being used after a rebuild
testEnvironment: '@ton/sandbox/jest-environment',
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
reporters: ['default', ['@ton/sandbox/jest-reporter', {}]],
};
export default config;

7
jest.setup.ts Normal file
View File

@@ -0,0 +1,7 @@
import { buildAllTact } from '@ton/blueprint';
import * as dotenv from 'dotenv';
export default async function () {
dotenv.config(); // Load environment variables from .env
await buildAllTact();
}

7876
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "SmartGift",
"version": "0.0.1",
"scripts": {
"bp": "blueprint",
"start": "blueprint run",
"build": "blueprint build",
"test": "jest --verbose",
"tui": "cd tui && npm start",
"createWallet": "npx ts-node scripts/createWallet.ts",
"release": "blueprint pack && npm publish --access public"
},
"dependencies": {
"@opentui/core": "^0.1.86",
"@ton/core": "~0",
"@types/readline-sync": "^1.4.8",
"buffer": "^6.0.3",
"ed2curve": "^0.3.0",
"readline-sync": "^1.4.10",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1"
},
"devDependencies": {
"@tact-lang/compiler": ">=1.6.13 <2.0.0",
"@ton-community/func-js": ">=0.10.0",
"@ton/blueprint": ">=0.40.0",
"@ton/crypto": "^3.3.0",
"@ton/sandbox": ">=0.37.0",
"@ton/test-utils": ">=0.11.0",
"@ton/tolk-js": ">=1.0.0",
"@ton/ton": ">=15.3.1 <16.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.17.2",
"dotenv": "^17.3.1",
"jest": "^30.0.5",
"prettier": "^3.6.2",
"ts-jest": "^29.4.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
}
}

31
scripts/createWallet.ts Normal file
View File

@@ -0,0 +1,31 @@
import { mnemonicNew, mnemonicToPrivateKey } from '@ton/crypto';
import { WalletContractV4 } from '@ton/ton';
import { mkdirSync, writeFileSync } from 'fs';
export async function createWallet() {
let mnemonic = await mnemonicNew();
let kp = await mnemonicToPrivateKey(mnemonic);
let contract = WalletContractV4.create({
workchain: 0,
publicKey: kp.publicKey,
});
// Create wallets directory
mkdirSync('wallets', { recursive: true });
console.log('Mnemonic:', mnemonic);
console.log('Address:', contract.address.toString());
// create file with wallet data in readable format
const content = `WALLET_MNEMONIC="${mnemonic.join(' ')}"
WALLET_VERSION="V4"
WALLET_ADDRESS="${contract.address.toString()}"`;
writeFileSync('wallets/wallet.env', content);
console.log('Wallet data saved to wallets/wallet.env');
return { mnemonic, contract };
}
createWallet()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,32 @@
import { randomBytes } from 'crypto';
import { keyPairFromSeed } from '@ton/crypto';
import { encryptContent, decryptContent } from './crypto';
import { client, getKeypair, getWallet } from '../toolchain';
import { WalletContractV4 } from '@ton/ton';
describe('Crypto functions', () => {
test('encrypt and decrypt content', async () => {
const sender = await getKeypair();
const recipient = keyPairFromSeed(randomBytes(32));
const originalContent = 'Secret message';
const encrypted = await encryptContent(sender.secretKey, recipient.publicKey, originalContent);
const decrypted = await decryptContent(recipient.secretKey, sender.publicKey, encrypted);
expect(decrypted).toBe(originalContent);
});
test('decrypt with wrong key fails', async () => {
const senderSeed = Buffer.from(randomBytes(32));
const recipientSeed = Buffer.from(randomBytes(32));
const wrongRecipientSeed = Buffer.from(randomBytes(32));
const sender = keyPairFromSeed(senderSeed);
const recipient = await getKeypair();
const wrongRecipient = keyPairFromSeed(wrongRecipientSeed);
const originalContent = 'Secret message';
const encrypted = await encryptContent(sender.secretKey, recipient.publicKey, originalContent);
await expect(decryptContent(wrongRecipient.secretKey, sender.publicKey, encrypted)).rejects.toThrow();
});
});

168
scripts/crypto/crypto.ts Normal file
View File

@@ -0,0 +1,168 @@
import { keyPairFromSeed, keyPairFromSecretKey, sign, signVerify, KeyPair, getSecureRandomBytes } from '@ton/crypto';
import nacl from 'tweetnacl';
const ed2curve = require('ed2curve');
import { client, getKeypair } from '../toolchain';
import { Content } from '../../build/Gift/Gift_Gift';
import { Address } from '@ton/core';
import { WalletContractV4 } from '@ton/ton';
/**
* Converts a bigint (uint256) to Buffer (32 bytes, big-endian).
* @param bigIntValue The bigint value to convert.
* @param byteLength The byte length (default 32 for uint256).
* @returns Buffer The converted byte buffer.
*/
function bigIntToBuffer(bigIntValue: bigint, byteLength: number = 32): Buffer {
const buffer = Buffer.alloc(byteLength);
for (let i = 0; i < byteLength; i++) {
buffer[byteLength - 1 - i] = Number((bigIntValue >> BigInt(i * 8)) & 0xffn);
}
return buffer;
}
/**
* Get the public key of the recipient wallet.
* @param address The recipient's TON address.
* @returns Promise<Buffer> The recipient's public key as Buffer.
*/
export async function getRecipientPublicKey(address: Address): Promise<Buffer> {
console.log(`Fetching public key for: ${address.toString()}`);
return client.callGetMethod(address, 'get_public_key')
.then((result) => {
return bigIntToBuffer(result.stack.readBigNumber());
})
.catch((error) => {
console.error('Error fetching recipient public key:', error);
throw error;
});
}
/**
* Encrypt a Content message using sender's private key and recipient's public key.
* @param content The Content to encrypt.
* @param senderSecretKey The sender's secret key.
* @param recipientPublicKey The recipient's public key.
* @returns Promise<Uint8Array> The encrypted message data (nonce + encrypted).
*/
export async function encryptMessage(
content: Content,
senderSecretKey: Buffer,
recipientPublicKey: Buffer,
): Promise<Uint8Array> {
// Serialize Content to JSON string
const contentString = JSON.stringify(content);
const contentBuffer = new Uint8Array(Buffer.from(contentString, 'utf-8'));
// Convert Ed25519 keys to Curve25519
const senderSecretCurve = ed2curve.convertSecretKey(senderSecretKey);
const recipientPublicCurve = ed2curve.convertPublicKey(recipientPublicKey);
if (!senderSecretCurve || !recipientPublicCurve) {
throw new Error('Invalid key conversion');
}
// Generate a random nonce
const nonceBuffer = await getSecureRandomBytes(24);
const nonce = new Uint8Array(nonceBuffer);
// Encrypt using NaCl box
const encrypted = nacl.box(contentBuffer, nonce, recipientPublicCurve, senderSecretCurve);
// Return nonce + encrypted
const combined = new Uint8Array(nonce.length + encrypted.length);
combined.set(nonce, 0);
combined.set(encrypted, nonce.length);
return combined;
}
/**
* Decrypt an encrypted message using recipient's private key and sender's public key.
* @param encryptedData The encrypted data (nonce + encrypted).
* @param recipientSecretKey The recipient's secret key.
* @param senderPublicKey The sender's public key.
* @returns Promise<Content> The decrypted Content.
*/
export async function decryptMessage(
encryptedData: Uint8Array,
recipientSecretKey: Buffer,
senderPublicKey: Buffer,
): Promise<Content> {
// Extract nonce (first 24 bytes)
const nonce = encryptedData.slice(0, 24);
const encrypted = encryptedData.slice(24);
// Convert Ed25519 keys to Curve25519
const recipientSecretCurve = ed2curve.convertSecretKey(recipientSecretKey);
const senderPublicCurve = ed2curve.convertPublicKey(senderPublicKey);
if (!recipientSecretCurve || !senderPublicCurve) {
throw new Error('Invalid key conversion');
}
// Decrypt using NaCl box open
const decrypted = nacl.box.open(encrypted, nonce, senderPublicCurve, recipientSecretCurve);
if (!decrypted) {
throw new Error('Decryption failed');
}
const contentString = new Buffer(decrypted).toString('utf-8');
const content: Content = JSON.parse(contentString);
return content;
}
/**
* Encrypt a string content using sender's private key and recipient's public key.
* @param senderSecretKey The sender's secret key.
* @param recipientPublicKey The recipient's public key.
* @param content The string to encrypt.
* @returns Promise<Uint8Array> The encrypted data.
*/
export async function encryptContent(
senderSecretKey: Buffer,
recipientPublicKey: Buffer,
content: string,
): Promise<Uint8Array> {
const contentBuffer = new Uint8Array(Buffer.from(content, 'utf-8'));
// Convert Ed25519 keys to Curve25519
const senderSecretCurve = ed2curve.convertSecretKey(senderSecretKey);
const recipientPublicCurve = ed2curve.convertPublicKey(recipientPublicKey);
if (!senderSecretCurve || !recipientPublicCurve) {
throw new Error('Invalid key conversion');
}
const nonceBuffer = await getSecureRandomBytes(24);
const nonce = new Uint8Array(nonceBuffer);
const encrypted = nacl.box(contentBuffer, nonce, recipientPublicCurve, senderSecretCurve);
const combined = new Uint8Array(nonce.length + encrypted.length);
combined.set(nonce, 0);
combined.set(encrypted, nonce.length);
return combined;
}
/**
* Decrypt encrypted content using recipient's private key and sender's public key.
* @param recipientSecretKey The recipient's secret key.
* @param senderPublicKey The sender's public key.
* @param encrypted The encrypted data.
* @returns Promise<string> The decrypted string.
*/
export async function decryptContent(
recipientSecretKey: Buffer,
senderPublicKey: Buffer,
encrypted: Uint8Array,
): Promise<string> {
// Convert Ed25519 keys to Curve25519
const recipientSecretCurve = ed2curve.convertSecretKey(recipientSecretKey);
const senderPublicCurve = ed2curve.convertPublicKey(senderPublicKey);
if (!recipientSecretCurve || !senderPublicCurve) {
throw new Error('Invalid key conversion');
}
const nonce = encrypted.slice(0, 24);
const encryptedPart = encrypted.slice(24);
const decrypted = nacl.box.open(encryptedPart, nonce, senderPublicCurve, recipientSecretCurve);
if (!decrypted) {
throw new Error('Decryption failed');
}
return new Buffer(decrypted).toString('utf-8');
}

42
scripts/deployGift.ts Normal file
View File

@@ -0,0 +1,42 @@
import { toNano, Address } from '@ton/core';
import { Gift } from '../build/Gift/Gift_Gift';
import { NetworkProvider } from '@ton/blueprint';
import { mnemonicToPrivateKey } from '@ton/crypto';
import { WalletContractV4 } from '@ton/ton';
import { encryptMessage, getRecipientPublicKey } from './crypto/crypto';
export async function run(provider: NetworkProvider) {
const ui = provider.ui();
let wallet = provider.sender();
let kp = await mnemonicToPrivateKey(process.env.WALLET_MNEMONIC!!.split(' '));
let walletContract = provider.open(
WalletContractV4.create({
workchain: provider.sender().address!!.workChain,
publicKey: kp.publicKey,
}),
);
let receiverAddress = await ui.inputAddress('Receiver address');
let text = await ui.input('Text content');
let imageUrlString = null; // await ui.input('ImageUrl (optional)');
let content = { $$type: 'Content' as const, content: text, imageUrl: imageUrlString || null };
let recipientPublicKey = await getRecipientPublicKey(receiverAddress);
let encrypted = await encryptMessage(content, kp.secretKey, recipientPublicKey);
let encryptedContent = {
$$type: 'Content' as const,
content: Buffer.from(encrypted).toString('base64'),
imageUrl: null,
};
const gift = provider.open(await Gift.fromInit(encryptedContent, receiverAddress));
await wallet.send({
to: gift.address,
value: toNano('0.05'),
init: gift.init,
});
await provider.waitForDeploy(gift.address);
}

104
scripts/toolchain.ts Normal file
View File

@@ -0,0 +1,104 @@
import { Address, toNano } from '@ton/core';
import { Gift, Content, storeContent, storeNotification } from '../build/Gift/Gift_Gift';
import { createNetworkProvider, NetworkProvider, sleep, UIProvider } from '@ton/blueprint';
import { mnemonicToPrivateKey, sign } from '@ton/crypto';
import { TonClient, TonClientParameters, WalletContractV4, beginCell } from '@ton/ton';
import { signatureOf } from '@tact-lang/compiler';
import { Sign } from 'crypto';
import { decryptContent, encryptContent, getRecipientPublicKey } from './crypto/crypto';
export const client = new TonClient({
endpoint: 'https://testnet.toncenter.com/api/v2/jsonRPC',
});
export async function getKeypair() {
let kp = await mnemonicToPrivateKey(process.env.WALLET_MNEMONIC!!.split(' '));
return kp;
}
export async function getWallet(client: TonClient) {
let kp = await getKeypair();
let walletContract = client.open(
WalletContractV4.create({
workchain: 0,
publicKey: kp.publicKey,
}),
);
return walletContract;
}
export function myAddress() {
return Address.parse(process.env.WALLET_ADDRESS!!);
}
export async function deployGift(np: NetworkProvider, recipient: Address, content: string) {
let kp = await getKeypair();
let walletContract = await getWallet(client);
let recipientPublicKey = await getRecipientPublicKey(recipient);
let encryptedContent = await encryptContent(kp.secretKey, recipientPublicKey, content);
const gift = np.open(
await Gift.fromInit(
{ $$type: 'Content' as const, content: Buffer.from(encryptedContent).toString('base64') },
recipient,
),
);
await np.sender().send({
to: gift.address,
value: toNano('0.05'),
init: gift.init,
});
// try 45 times with 2 seconds delay to get gift deployed
for (let i = 0; i < 45; i++) {
try {
if ((await client.getContractState(gift.address)).state == 'active') {
return gift;
}
} catch (e) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
throw new Error('Gift deployment failed after multiple attempts');
}
export async function getListOfGifts(address: Address) {
let notificationHash = beginCell()
.store(storeNotification({ $$type: 'Notification' }))
.endCell()
.hash();
let listOfGifts = await client
.getTransactions(address, {
limit: 50,
})
.then((transactions) =>
transactions.filter(
(tx) =>
tx.inMessage != null && tx.inMessage?.body.hash().equals(notificationHash) && tx.inMessage.info.src,
),
);
return listOfGifts.map((tx) => {
let giftContractAddress = tx.inMessage!.info.src! as Address;
return {
gift: Gift.fromAddress(giftContractAddress),
address: giftContractAddress,
time: tx.now || 0,
};
});
}
export async function readGift(gift: Gift) {
let delay = 1000;
let contentMethod = await client.runMethod(gift.address, 'content');
await sleep(delay);
let content = contentMethod.stack.readString() || '';
let senderMethod = await client.runMethod(gift.address, 'owner');
await sleep(delay);
let senderAddress = senderMethod.stack.readAddress();
let senderPublicKey = await getRecipientPublicKey(senderAddress);
await sleep(delay);
console.log(`Gift content (encrypted): ${content}`);
await sleep(delay);
return decryptContent((await getKeypair()).secretKey, senderPublicKey, Buffer.from(content, 'base64'));
}

90
scripts/tui.ts Normal file
View File

@@ -0,0 +1,90 @@
import { NetworkProvider, sleep, UIProvider } from '@ton/blueprint';
import { encryptContent, getRecipientPublicKey } from './crypto/crypto';
import { getKeypair, deployGift, getListOfGifts, readGift, myAddress, client } from './toolchain';
import { Address } from '@ton/core';
function checkEnv() {
if (!process.env.WALLET_MNEMONIC) {
throw new Error('WALLET_MNEMONIC is not set in environment variables');
}
if (!process.env.WALLET_ADDRESS) {
throw new Error('WALLET_ADDRESS is not set in environment variables');
}
if (process.env.WALLET_VERSION !== 'V4') {
throw new Error('WALLET_VERSION must be set to V4 in environment variables');
}
}
export async function run(provider: NetworkProvider) {
const ui = provider.ui();
checkEnv();
while (true) {
const choice = await ui.choose('Select an option:', ['Create gift', 'Receive gift', 'Exit'], (c) => c);
switch (choice) {
case 'Create gift':
await handleCreateGift(ui);
break;
case 'Receive gift':
await handleReceiveGift(ui);
break;
case 'Exit':
ui.write('Goodbye!');
return;
}
}
async function handleCreateGift(ui: UIProvider) {
const text = await ui.input('Enter text to send:');
const recipientAddress = await ui.inputAddress(
'Enter recipient TON address:',
Address.parse('0QDl9pZ0wzHiYCKxyzdbR5WN6kAVTS2Ggra-Fx-O182Ms47d'),
);
ui.write(`Original text: ${text}`);
try {
const senderKp = await getKeypair();
const recipientPublicKey = await getRecipientPublicKey(recipientAddress);
const encrypted = await encryptContent(senderKp.secretKey, recipientPublicKey, text);
ui.write(`Encrypted text: ${Buffer.from(encrypted).toString('base64')}`);
// Placeholder for sending
ui.setActionPrompt('Sending to blockchain...');
// create gift here
await deployGift(provider, recipientAddress, text);
ui.clearActionPrompt();
ui.write('Gift sent successfully!');
} catch (error) {
ui.write(`Error: ${(error as Error).message}`);
}
}
async function handleReceiveGift(ui: any) {
try {
const gifts = await getListOfGifts(myAddress());
if (gifts.length === 0) {
ui.write('No gifts found.');
await sleep(2000);
return;
}
const choices = gifts;
const selectedGift = await ui.choose('Select a gift:', choices, (g: any) => {
const date = new Date(g.time * 1000);
const timeStr = date.toTimeString().slice(0, 5); // HH:MM
const dateStr = date.toLocaleDateString('en-GB'); // dd/mm/yyyy, but we need dd-mm-yy
const dayMonthYear = `${date.getDate().toString().padStart(2, '0')}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getFullYear().toString().slice(-2)}`;
const addressStr = g.gift?.address?.toString() || 'Unknown';
return `${timeStr} ${dayMonthYear} ${addressStr}`;
});
ui.write(`Selected gift: ${selectedGift.address?.toString() || 'Unknown'}`);
ui.setActionPrompt('Decrypting gift...');
const content = await readGift(selectedGift.gift);
ui.clearActionPrompt();
ui.write(`Decrypted text: ${content}`);
} catch (error) {
ui.write(`Error: ${(error as Error).message}`);
}
}
}

15
tact.config.json Normal file
View File

@@ -0,0 +1,15 @@
{
"$schema": "https://raw.githubusercontent.com/tact-lang/tact/main/src/config/configSchema.json",
"projects": [
{
"name": "Gift",
"path": "contracts/gift.tact",
"output": "build/Gift",
"options": {
"debug": false,
"external": false
},
"mode": "full"
}
]
}

41
tests/Gift.spec.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox';
import { toNano } from '@ton/core';
import { Gift } from '../build/Gift/Gift_Gift';
import '@ton/test-utils';
describe('Gift', () => {
let blockchain: Blockchain;
let deployer: SandboxContract<TreasuryContract>;
let gift: SandboxContract<Gift>;
beforeEach(async () => {
blockchain = await Blockchain.create();
deployer = await blockchain.treasury('deployer');
const content = { $$type: 'Content' as const, content: 'test', imageUrl: null };
const receiver = deployer.address;
gift = blockchain.openContract(await Gift.fromInit(content, receiver));
const deployResult = await gift.send(
deployer.getSender(),
{
value: toNano('0.05'),
},
null,
);
expect(deployResult.transactions).toHaveTransaction({
from: deployer.address,
to: gift.address,
deploy: true,
success: true,
});
});
it('should deploy', async () => {
// the check is done inside beforeEach
// blockchain and gift are ready to use
});
});

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"outDir": "dist",
"module": "commonjs",
"declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

4177
yarn.lock Normal file

File diff suppressed because it is too large Load Diff