From 86f5e127c004e541c155e0fa94afedf05fed1e84 Mon Sep 17 00:00:00 2001 From: Tutus Development Date: Sat, 20 Dec 2025 02:57:08 +0000 Subject: [PATCH] Add VTS (Value Transfer System) native contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VTS is programmable money with spending restrictions and automatic tax accounting, designed to replace GAS as the primary user-facing currency. Key features: - NEP-17 compliant token (symbol: VTS, decimals: 8) - Spending category restrictions (Food, Shelter, Medical, Education, Transport) - Vendor registration and management - Restricted VTS can only be spent at matching vendor categories - Automatic tax withholding via payWage() - Transaction recording for tax reporting - Tax summary queries (getTransactions, getIncomeForPeriod, getTaxWithheld, getDeductibleExpenses, getTaxSummary) Files added: - pkg/core/native/vts.go: Main VTS contract implementation - pkg/core/state/vts.go: VTS state structures (VTSBalance, Vendor, TaxConfig, etc.) - pkg/core/native/native_test/vts_test.go: Comprehensive test suite Files modified: - pkg/core/native/contract.go: Added IVTS interface and VTS() accessor - pkg/core/native/nativenames/names.go: Added VTS constant - pkg/core/native/nativeids/ids.go: Added VTS ID (-14) - pkg/core/native/nativehashes/hashes.go: Added VTS hash - pkg/core/native/native_test/management_test.go: Updated test fixture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- pkg/core/native/contract.go | 31 + .../native/native_test/management_test.go | 1 + pkg/core/native/native_test/vts_test.go | 527 ++++++ pkg/core/native/nativehashes/hashes.go | 6 + pkg/core/native/nativeids/ids.go | 2 + pkg/core/native/nativenames/names.go | 5 +- pkg/core/native/vts.go | 1565 +++++++++++++++++ pkg/core/state/vts.go | 452 +++++ 8 files changed, 2588 insertions(+), 1 deletion(-) create mode 100644 pkg/core/native/native_test/vts_test.go create mode 100644 pkg/core/native/vts.go create mode 100644 pkg/core/state/vts.go diff --git a/pkg/core/native/contract.go b/pkg/core/native/contract.go index d461ff5..cbcc1f3 100644 --- a/pkg/core/native/contract.go +++ b/pkg/core/native/contract.go @@ -134,6 +134,25 @@ type ( // HasPermissionInternal checks if address has permission via roles. HasPermissionInternal(d *dao.Simple, address util.Uint160, resource, action string, scope state.Scope, blockHeight uint32) bool } + + // IVTS is an interface required from native VTS contract for + // interaction with Blockchain and other native contracts. + // VTS is programmable money with spending restrictions and automatic tax accounting. + IVTS interface { + interop.Contract + // BalanceOf returns total VTS balance (unrestricted + all restricted). + BalanceOf(d *dao.Simple, acc util.Uint160) *big.Int + // UnrestrictedBalanceOf returns only unrestricted VTS balance. + UnrestrictedBalanceOf(d *dao.Simple, acc util.Uint160) *big.Int + // Mint mints unrestricted VTS to the account. + Mint(ic *interop.Context, to util.Uint160, amount *big.Int) + // MintRestricted mints VTS with spending category restrictions. + MintRestricted(ic *interop.Context, to util.Uint160, amount *big.Int, category uint8) + // Burn burns unrestricted VTS from the account. + Burn(ic *interop.Context, from util.Uint160, amount *big.Int) + // IsVendor returns true if address is a registered active vendor. + IsVendor(d *dao.Simple, addr util.Uint160) bool + } ) // Contracts is a convenient wrapper around an arbitrary set of native contracts @@ -261,6 +280,12 @@ func (cs *Contracts) RoleRegistry() IRoleRegistry { return cs.ByName(nativenames.RoleRegistry).(IRoleRegistry) } +// VTS returns native IVTS contract implementation. It panics if +// there's no contract with proper name in cs. +func (cs *Contracts) VTS() IVTS { + return cs.ByName(nativenames.VTS).(IVTS) +} + // NewDefaultContracts returns a new set of default native contracts. func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { mgmt := NewManagement() @@ -319,6 +344,11 @@ func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { // Set RoleRegistry on PersonToken for cross-contract integration personToken.RoleRegistry = roleRegistry + // Create VTS (Value Transfer System) contract + vts := newVTS() + vts.NEO = neo + vts.RoleRegistry = roleRegistry + return []interop.Contract{ mgmt, s, @@ -333,5 +363,6 @@ func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { treasury, personToken, roleRegistry, + vts, } } diff --git a/pkg/core/native/native_test/management_test.go b/pkg/core/native/native_test/management_test.go index d6afe55..dad85e9 100644 --- a/pkg/core/native/native_test/management_test.go +++ b/pkg/core/native/native_test/management_test.go @@ -53,6 +53,7 @@ var ( nativenames.Oracle: `{"id":-9,"hash":"0xfe924b7cfe89ddd271abaf7210a80a7e11178758","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0A=","checksum":2663858513},"manifest":{"name":"OracleContract","abi":{"methods":[{"name":"finish","offset":0,"parameters":[],"returntype":"Void","safe":false},{"name":"getPrice","offset":7,"parameters":[],"returntype":"Integer","safe":true},{"name":"request","offset":14,"parameters":[{"name":"url","type":"String"},{"name":"filter","type":"String"},{"name":"callback","type":"String"},{"name":"userData","type":"Any"},{"name":"gasForResponse","type":"Integer"}],"returntype":"Void","safe":false},{"name":"setPrice","offset":21,"parameters":[{"name":"price","type":"Integer"}],"returntype":"Void","safe":false},{"name":"verify","offset":28,"parameters":[],"returntype":"Boolean","safe":true}],"events":[{"name":"OracleRequest","parameters":[{"name":"Id","type":"Integer"},{"name":"RequestContract","type":"Hash160"},{"name":"Url","type":"String"},{"name":"Filter","type":"String"}]},{"name":"OracleResponse","parameters":[{"name":"Id","type":"Integer"},{"name":"OriginalTx","type":"Hash256"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, nativenames.PersonToken: `{"id":-12,"hash":"0x21a5728a3a261c77c7df30d47aa4dbdef134af04","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQA==","checksum":2426471238},"manifest":{"name":"PersonToken","abi":{"methods":[{"name":"approveRecovery","offset":0,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"cancelRecovery","offset":7,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"createChallenge","offset":14,"parameters":[{"name":"owner","type":"Hash160"},{"name":"purpose","type":"String"}],"returntype":"Array","safe":false},{"name":"executeRecovery","offset":21,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"exists","offset":28,"parameters":[{"name":"owner","type":"Hash160"}],"returntype":"Boolean","safe":true},{"name":"fulfillChallenge","offset":35,"parameters":[{"name":"challengeId","type":"ByteArray"},{"name":"signature","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"getAttribute","offset":42,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"}],"returntype":"Array","safe":true},{"name":"getChallenge","offset":49,"parameters":[{"name":"challengeId","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"getRecoveryRequest","offset":56,"parameters":[{"name":"requestId","type":"ByteArray"}],"returntype":"Array","safe":true},{"name":"getToken","offset":63,"parameters":[{"name":"owner","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"getTokenByID","offset":70,"parameters":[{"name":"tokenId","type":"Integer"}],"returntype":"Array","safe":true},{"name":"initiateRecovery","offset":77,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"newOwner","type":"Hash160"},{"name":"evidence","type":"ByteArray"}],"returntype":"ByteArray","safe":false},{"name":"register","offset":84,"parameters":[{"name":"owner","type":"Hash160"},{"name":"personHash","type":"ByteArray"},{"name":"isEntity","type":"Boolean"},{"name":"recoveryHash","type":"ByteArray"}],"returntype":"ByteArray","safe":false},{"name":"reinstate","offset":91,"parameters":[{"name":"owner","type":"Hash160"}],"returntype":"Boolean","safe":false},{"name":"requireCoreRole","offset":98,"parameters":[{"name":"coreRole","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"requirePermission","offset":105,"parameters":[{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"String"}],"returntype":"Integer","safe":true},{"name":"requireRole","offset":112,"parameters":[{"name":"roleId","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"revoke","offset":119,"parameters":[{"name":"owner","type":"Hash160"},{"name":"reason","type":"String"}],"returntype":"Boolean","safe":false},{"name":"revokeAttribute","offset":126,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"reason","type":"String"}],"returntype":"Boolean","safe":false},{"name":"setAttribute","offset":133,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"valueHash","type":"ByteArray"},{"name":"valueEnc","type":"ByteArray"},{"name":"expiresAt","type":"Integer"},{"name":"disclosureLevel","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"suspend","offset":140,"parameters":[{"name":"owner","type":"Hash160"},{"name":"reason","type":"String"}],"returntype":"Boolean","safe":false},{"name":"totalSupply","offset":147,"parameters":[],"returntype":"Integer","safe":true},{"name":"validateCaller","offset":154,"parameters":[],"returntype":"Array","safe":true},{"name":"verifyAttribute","offset":161,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"expectedHash","type":"ByteArray"}],"returntype":"Boolean","safe":true},{"name":"verifyAuth","offset":168,"parameters":[{"name":"tokenId","type":"Integer"},{"name":"purpose","type":"String"},{"name":"maxAge","type":"Integer"}],"returntype":"Boolean","safe":true}],"events":[{"name":"PersonTokenCreated","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"owner","type":"Hash160"},{"name":"createdAt","type":"Integer"}]},{"name":"PersonTokenSuspended","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"reason","type":"String"},{"name":"suspendedBy","type":"Hash160"}]},{"name":"PersonTokenReinstated","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"reinstatedBy","type":"Hash160"}]},{"name":"PersonTokenRevoked","parameters":[{"name":"tokenId","type":"ByteArray"},{"name":"reason","type":"String"},{"name":"revokedBy","type":"Hash160"}]},{"name":"AttributeSet","parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"attestor","type":"Hash160"},{"name":"expiresAt","type":"Integer"}]},{"name":"AttributeRevoked","parameters":[{"name":"tokenId","type":"Integer"},{"name":"key","type":"String"},{"name":"revokedBy","type":"Hash160"},{"name":"reason","type":"String"}]},{"name":"AuthChallengeCreated","parameters":[{"name":"challengeId","type":"ByteArray"},{"name":"tokenId","type":"Integer"},{"name":"purpose","type":"String"},{"name":"expiresAt","type":"Integer"}]},{"name":"AuthenticationSuccess","parameters":[{"name":"tokenId","type":"Integer"},{"name":"purpose","type":"String"},{"name":"timestamp","type":"Integer"}]},{"name":"RecoveryInitiated","parameters":[{"name":"tokenId","type":"Integer"},{"name":"requestId","type":"ByteArray"},{"name":"delayUntil","type":"Integer"}]},{"name":"RecoveryApproval","parameters":[{"name":"requestId","type":"ByteArray"},{"name":"approver","type":"Hash160"},{"name":"approvalCount","type":"Integer"},{"name":"required","type":"Integer"}]},{"name":"RecoveryExecuted","parameters":[{"name":"tokenId","type":"Integer"},{"name":"oldOwner","type":"Hash160"},{"name":"newOwner","type":"Hash160"}]},{"name":"RecoveryCancelled","parameters":[{"name":"requestId","type":"ByteArray"},{"name":"cancelledBy","type":"Hash160"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, nativenames.RoleRegistry: `{"id":-13,"hash":"0x52200161c6f0b581b590d41af8ccc577dc7477a9","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQA==","checksum":174904780},"manifest":{"name":"RoleRegistry","abi":{"methods":[{"name":"assignPermission","offset":0,"parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"createRole","offset":7,"parameters":[{"name":"name","type":"String"},{"name":"description","type":"String"},{"name":"parentID","type":"Integer"}],"returntype":"Integer","safe":false},{"name":"deleteRole","offset":14,"parameters":[{"name":"roleID","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"getPermissions","offset":21,"parameters":[{"name":"roleID","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getRole","offset":28,"parameters":[{"name":"roleID","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getRoleByName","offset":35,"parameters":[{"name":"name","type":"String"}],"returntype":"Array","safe":true},{"name":"getRolesForAddress","offset":42,"parameters":[{"name":"address","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"grantRole","offset":49,"parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"},{"name":"expiresAt","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"hasPermission","offset":56,"parameters":[{"name":"address","type":"Hash160"},{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"Integer"}],"returntype":"Boolean","safe":true},{"name":"hasRole","offset":63,"parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"}],"returntype":"Boolean","safe":true},{"name":"removePermission","offset":70,"parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"}],"returntype":"Boolean","safe":false},{"name":"revokeRole","offset":77,"parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"totalRoles","offset":84,"parameters":[],"returntype":"Integer","safe":true}],"events":[{"name":"RoleCreated","parameters":[{"name":"roleID","type":"Integer"},{"name":"name","type":"String"},{"name":"parentID","type":"Integer"},{"name":"createdBy","type":"Hash160"}]},{"name":"RoleDeleted","parameters":[{"name":"roleID","type":"Integer"},{"name":"deletedBy","type":"Hash160"}]},{"name":"RoleGranted","parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"},{"name":"expiresAt","type":"Integer"},{"name":"grantedBy","type":"Hash160"}]},{"name":"RoleRevoked","parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"},{"name":"revokedBy","type":"Hash160"}]},{"name":"PermissionAssigned","parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"Integer"}]},{"name":"PermissionRemoved","parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, + nativenames.VTS: `{"id":-14,"hash":"0x893659b7f9d0a383d960234841ff8a6d825e3468","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0A=","checksum":3508376793},"manifest":{"name":"VTS","abi":{"methods":[{"name":"balanceDetails","offset":0,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"balanceOf","offset":7,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Integer","safe":true},{"name":"burn","offset":14,"parameters":[{"name":"from","type":"Hash160"},{"name":"amount","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"canSpendAt","offset":21,"parameters":[{"name":"account","type":"Hash160"},{"name":"vendor","type":"Hash160"},{"name":"amount","type":"Integer"}],"returntype":"Boolean","safe":true},{"name":"convertToUnrestricted","offset":28,"parameters":[{"name":"account","type":"Hash160"},{"name":"category","type":"Integer"},{"name":"amount","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"deactivateVendor","offset":35,"parameters":[{"name":"address","type":"Hash160"}],"returntype":"Boolean","safe":false},{"name":"decimals","offset":42,"parameters":[],"returntype":"Integer","safe":true},{"name":"getDeductibleExpenses","offset":49,"parameters":[{"name":"account","type":"Hash160"},{"name":"startBlock","type":"Integer"},{"name":"endBlock","type":"Integer"},{"name":"category","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"getIncomeForPeriod","offset":56,"parameters":[{"name":"account","type":"Hash160"},{"name":"startBlock","type":"Integer"},{"name":"endBlock","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"getTaxConfig","offset":63,"parameters":[],"returntype":"Array","safe":true},{"name":"getTaxSummary","offset":70,"parameters":[{"name":"account","type":"Hash160"},{"name":"startBlock","type":"Integer"},{"name":"endBlock","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getTaxWithheld","offset":77,"parameters":[{"name":"account","type":"Hash160"},{"name":"startBlock","type":"Integer"},{"name":"endBlock","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"getTransactions","offset":84,"parameters":[{"name":"account","type":"Hash160"},{"name":"startBlock","type":"Integer"},{"name":"endBlock","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getVendor","offset":91,"parameters":[{"name":"address","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"getVendorCategories","offset":98,"parameters":[{"name":"address","type":"Hash160"}],"returntype":"Integer","safe":true},{"name":"isVendor","offset":105,"parameters":[{"name":"address","type":"Hash160"}],"returntype":"Boolean","safe":true},{"name":"issueTaxRefund","offset":112,"parameters":[{"name":"account","type":"Hash160"},{"name":"amount","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"mint","offset":119,"parameters":[{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"mintRestricted","offset":126,"parameters":[{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"category","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"payWage","offset":133,"parameters":[{"name":"employer","type":"Hash160"},{"name":"employee","type":"Hash160"},{"name":"grossAmount","type":"Integer"},{"name":"taxRate","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"registerVendor","offset":140,"parameters":[{"name":"address","type":"Hash160"},{"name":"name","type":"String"},{"name":"categories","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"restrictedBalanceOf","offset":147,"parameters":[{"name":"account","type":"Hash160"},{"name":"category","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"setTaxConfig","offset":154,"parameters":[{"name":"incomeRate","type":"Integer"},{"name":"salesRate","type":"Integer"},{"name":"treasuryAddress","type":"Hash160"},{"name":"exemptCategories","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"spend","offset":161,"parameters":[{"name":"from","type":"Hash160"},{"name":"vendor","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"data","type":"Any"}],"returntype":"Boolean","safe":false},{"name":"symbol","offset":168,"parameters":[],"returntype":"String","safe":true},{"name":"totalSupply","offset":175,"parameters":[],"returntype":"Integer","safe":true},{"name":"transfer","offset":182,"parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"data","type":"Any"}],"returntype":"Boolean","safe":false},{"name":"unrestrictedBalanceOf","offset":189,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Integer","safe":true},{"name":"updateVendor","offset":196,"parameters":[{"name":"address","type":"Hash160"},{"name":"name","type":"String"},{"name":"categories","type":"Integer"}],"returntype":"Boolean","safe":false}],"events":[{"name":"Transfer","parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"}]},{"name":"Mint","parameters":[{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"category","type":"Integer"}]},{"name":"Burn","parameters":[{"name":"from","type":"Hash160"},{"name":"amount","type":"Integer"}]},{"name":"Spend","parameters":[{"name":"from","type":"Hash160"},{"name":"vendor","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"categoriesUsed","type":"Integer"}]},{"name":"VendorRegistered","parameters":[{"name":"address","type":"Hash160"},{"name":"name","type":"String"},{"name":"categories","type":"Integer"}]},{"name":"VendorUpdated","parameters":[{"name":"address","type":"Hash160"},{"name":"name","type":"String"},{"name":"categories","type":"Integer"}]},{"name":"VendorDeactivated","parameters":[{"name":"address","type":"Hash160"}]},{"name":"TaxWithheld","parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"grossAmount","type":"Integer"},{"name":"taxAmount","type":"Integer"},{"name":"taxRate","type":"Integer"}]},{"name":"TaxRefunded","parameters":[{"name":"account","type":"Hash160"},{"name":"amount","type":"Integer"}]},{"name":"ConvertedToUnrestricted","parameters":[{"name":"account","type":"Hash160"},{"name":"category","type":"Integer"},{"name":"amount","type":"Integer"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":["NEP-17"],"trusts":[],"extra":null},"updatecounter":0}`, } // cockatriceCSS holds serialized native contract states built for genesis block (with UpdateCounter 0) // under assumption that hardforks from Aspidochelone to Cockatrice (included) are enabled. diff --git a/pkg/core/native/native_test/vts_test.go b/pkg/core/native/native_test/vts_test.go new file mode 100644 index 0000000..2460ff1 --- /dev/null +++ b/pkg/core/native/native_test/vts_test.go @@ -0,0 +1,527 @@ +package native_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tutus-one/tutus-chain/pkg/core/native/nativenames" + "github.com/tutus-one/tutus-chain/pkg/core/state" + "github.com/tutus-one/tutus-chain/pkg/neotest" + "github.com/tutus-one/tutus-chain/pkg/vm/stackitem" +) + +func newVTSClient(t *testing.T) *neotest.ContractInvoker { + return newNativeClient(t, nativenames.VTS) +} + +// TestVTS_NEP17Compliance tests basic NEP-17 methods. +func TestVTS_NEP17Compliance(t *testing.T) { + c := newVTSClient(t) + + t.Run("symbol", func(t *testing.T) { + c.Invoke(t, "VTS", "symbol") + }) + + t.Run("decimals", func(t *testing.T) { + c.Invoke(t, 8, "decimals") + }) + + t.Run("totalSupply initial", func(t *testing.T) { + c.Invoke(t, 0, "totalSupply") + }) + + t.Run("balanceOf empty account", func(t *testing.T) { + acc := c.NewAccount(t) + c.Invoke(t, 0, "balanceOf", acc.ScriptHash()) + }) +} + +// TestVTS_Mint tests minting unrestricted VTS. +func TestVTS_Mint(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + acc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + userInvoker := c.WithSigners(acc) + + t.Run("non-committee cannot mint", func(t *testing.T) { + userInvoker.InvokeFail(t, "caller is not a committee member", "mint", acc.ScriptHash(), 1000_00000000) + }) + + t.Run("committee can mint", func(t *testing.T) { + committeeInvoker.Invoke(t, true, "mint", acc.ScriptHash(), 1000_00000000) + }) + + t.Run("balance updated", func(t *testing.T) { + c.Invoke(t, 1000_00000000, "balanceOf", acc.ScriptHash()) + }) + + t.Run("unrestricted balance updated", func(t *testing.T) { + c.Invoke(t, 1000_00000000, "unrestrictedBalanceOf", acc.ScriptHash()) + }) + + t.Run("totalSupply updated", func(t *testing.T) { + c.Invoke(t, 1000_00000000, "totalSupply") + }) +} + +// TestVTS_MintRestricted tests minting restricted VTS. +func TestVTS_MintRestricted(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + acc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + + t.Run("mint food-restricted VTS", func(t *testing.T) { + committeeInvoker.Invoke(t, true, "mintRestricted", acc.ScriptHash(), 500_00000000, state.CategoryFood) + }) + + t.Run("total balance includes restricted", func(t *testing.T) { + c.Invoke(t, 500_00000000, "balanceOf", acc.ScriptHash()) + }) + + t.Run("unrestricted is still zero", func(t *testing.T) { + c.Invoke(t, 0, "unrestrictedBalanceOf", acc.ScriptHash()) + }) + + t.Run("restricted balance correct", func(t *testing.T) { + c.Invoke(t, 500_00000000, "restrictedBalanceOf", acc.ScriptHash(), state.CategoryFood) + }) + + t.Run("other restricted category is zero", func(t *testing.T) { + c.Invoke(t, 0, "restrictedBalanceOf", acc.ScriptHash(), state.CategoryShelter) + }) +} + +// TestVTS_Burn tests burning unrestricted VTS. +func TestVTS_Burn(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + acc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + userInvoker := c.WithSigners(acc) + + // Mint first + committeeInvoker.Invoke(t, true, "mint", acc.ScriptHash(), 1000_00000000) + + t.Run("non-committee cannot burn", func(t *testing.T) { + userInvoker.InvokeFail(t, "caller is not a committee member", "burn", acc.ScriptHash(), 100_00000000) + }) + + t.Run("committee can burn", func(t *testing.T) { + committeeInvoker.Invoke(t, true, "burn", acc.ScriptHash(), 100_00000000) + }) + + t.Run("balance reduced", func(t *testing.T) { + c.Invoke(t, 900_00000000, "balanceOf", acc.ScriptHash()) + }) + + t.Run("totalSupply reduced", func(t *testing.T) { + c.Invoke(t, 900_00000000, "totalSupply") + }) +} + +// TestVTS_Transfer tests transferring unrestricted VTS. +func TestVTS_Transfer(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + sender := e.NewAccount(t) + receiver := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + senderInvoker := c.WithSigners(sender) + + // Mint to sender + committeeInvoker.Invoke(t, true, "mint", sender.ScriptHash(), 1000_00000000) + + t.Run("transfer without signature fails", func(t *testing.T) { + // Transfer returns false (not panic) when signature is missing + c.Invoke(t, false, "transfer", sender.ScriptHash(), receiver.ScriptHash(), 100_00000000, nil) + }) + + t.Run("transfer with signature succeeds", func(t *testing.T) { + senderInvoker.Invoke(t, true, "transfer", sender.ScriptHash(), receiver.ScriptHash(), 100_00000000, nil) + }) + + t.Run("sender balance reduced", func(t *testing.T) { + c.Invoke(t, 900_00000000, "balanceOf", sender.ScriptHash()) + }) + + t.Run("receiver balance increased", func(t *testing.T) { + c.Invoke(t, 100_00000000, "balanceOf", receiver.ScriptHash()) + }) + + t.Run("cannot transfer more than balance", func(t *testing.T) { + senderInvoker.Invoke(t, false, "transfer", sender.ScriptHash(), receiver.ScriptHash(), 10000_00000000, nil) + }) + + t.Run("cannot transfer negative", func(t *testing.T) { + // Transfer returns false (not panic) for negative amounts + senderInvoker.Invoke(t, false, "transfer", sender.ScriptHash(), receiver.ScriptHash(), -1, nil) + }) +} + +// TestVTS_VendorRegistration tests vendor management. +func TestVTS_VendorRegistration(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + vendorAcc := e.NewAccount(t) + userAcc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + userInvoker := c.WithSigners(userAcc) + + t.Run("non-committee cannot register vendor", func(t *testing.T) { + userInvoker.InvokeFail(t, "caller is not a committee member", "registerVendor", vendorAcc.ScriptHash(), "Test Store", state.CategoryFood) + }) + + t.Run("committee can register vendor", func(t *testing.T) { + committeeInvoker.Invoke(t, true, "registerVendor", vendorAcc.ScriptHash(), "Test Store", state.CategoryFood) + }) + + t.Run("isVendor returns true", func(t *testing.T) { + c.Invoke(t, true, "isVendor", vendorAcc.ScriptHash()) + }) + + t.Run("non-vendor returns false", func(t *testing.T) { + c.Invoke(t, false, "isVendor", userAcc.ScriptHash()) + }) + + t.Run("getVendorCategories", func(t *testing.T) { + c.Invoke(t, int64(state.CategoryFood), "getVendorCategories", vendorAcc.ScriptHash()) + }) + + t.Run("getVendor returns vendor info", func(t *testing.T) { + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array") + require.Equal(t, 6, len(arr)) // Vendor struct has 6 fields + }, "getVendor", vendorAcc.ScriptHash()) + }) + + t.Run("update vendor categories", func(t *testing.T) { + newCategories := state.CategoryFood | state.CategoryShelter + committeeInvoker.Invoke(t, true, "updateVendor", vendorAcc.ScriptHash(), "Test Store Updated", newCategories) + c.Invoke(t, int64(newCategories), "getVendorCategories", vendorAcc.ScriptHash()) + }) + + t.Run("deactivate vendor", func(t *testing.T) { + committeeInvoker.Invoke(t, true, "deactivateVendor", vendorAcc.ScriptHash()) + // isVendor should still return true (vendor exists) but Active=false + c.Invoke(t, false, "isVendor", vendorAcc.ScriptHash()) + }) +} + +// TestVTS_Spend tests spending at vendors. +func TestVTS_Spend(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + customer := e.NewAccount(t) + foodVendor := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + customerInvoker := c.WithSigners(customer) + + // Setup: Register food vendor and mint to customer + committeeInvoker.Invoke(t, true, "registerVendor", foodVendor.ScriptHash(), "Food Store", state.CategoryFood) + committeeInvoker.Invoke(t, true, "mint", customer.ScriptHash(), 300_00000000) + + t.Run("customer can spend at vendor", func(t *testing.T) { + customerInvoker.Invoke(t, true, "spend", customer.ScriptHash(), foodVendor.ScriptHash(), 50_00000000, nil) + }) + + t.Run("vendor received VTS", func(t *testing.T) { + c.Invoke(t, 50_00000000, "balanceOf", foodVendor.ScriptHash()) + }) + + t.Run("customer balance reduced", func(t *testing.T) { + c.Invoke(t, 250_00000000, "balanceOf", customer.ScriptHash()) + }) + + t.Run("cannot spend at non-vendor", func(t *testing.T) { + nonVendor := e.NewAccount(t) + customerInvoker.InvokeFail(t, "invalid or inactive vendor", "spend", customer.ScriptHash(), nonVendor.ScriptHash(), 10_00000000, nil) + }) +} + +// TestVTS_CanSpendAt tests the canSpendAt query method. +func TestVTS_CanSpendAt(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + customer := e.NewAccount(t) + vendor := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + + // Setup + committeeInvoker.Invoke(t, true, "registerVendor", vendor.ScriptHash(), "Store", state.CategoryFood) + committeeInvoker.Invoke(t, true, "mint", customer.ScriptHash(), 100_00000000) + + t.Run("can spend within balance", func(t *testing.T) { + c.Invoke(t, true, "canSpendAt", customer.ScriptHash(), vendor.ScriptHash(), 50_00000000) + }) + + t.Run("cannot spend more than balance", func(t *testing.T) { + c.Invoke(t, false, "canSpendAt", customer.ScriptHash(), vendor.ScriptHash(), 200_00000000) + }) +} + +// TestVTS_ConvertToUnrestricted tests converting restricted VTS to unrestricted. +func TestVTS_ConvertToUnrestricted(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + acc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + userInvoker := c.WithSigners(acc) + + // Mint restricted VTS + committeeInvoker.Invoke(t, true, "mintRestricted", acc.ScriptHash(), 500_00000000, state.CategoryFood) + + t.Run("non-committee cannot convert", func(t *testing.T) { + userInvoker.InvokeFail(t, "caller is not a committee member", "convertToUnrestricted", acc.ScriptHash(), state.CategoryFood, 100_00000000) + }) + + t.Run("committee can convert", func(t *testing.T) { + committeeInvoker.Invoke(t, true, "convertToUnrestricted", acc.ScriptHash(), state.CategoryFood, 100_00000000) + }) + + t.Run("restricted balance reduced", func(t *testing.T) { + c.Invoke(t, 400_00000000, "restrictedBalanceOf", acc.ScriptHash(), state.CategoryFood) + }) + + t.Run("unrestricted balance increased", func(t *testing.T) { + c.Invoke(t, 100_00000000, "unrestrictedBalanceOf", acc.ScriptHash()) + }) + + t.Run("total balance unchanged", func(t *testing.T) { + c.Invoke(t, 500_00000000, "balanceOf", acc.ScriptHash()) + }) +} + +// TestVTS_TaxConfig tests tax configuration. +func TestVTS_TaxConfig(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + treasuryAcc := e.NewAccount(t) + userAcc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + userInvoker := c.WithSigners(userAcc) + + t.Run("non-committee cannot set tax config", func(t *testing.T) { + userInvoker.InvokeFail(t, "caller is not a committee member", "setTaxConfig", 2500, 500, treasuryAcc.ScriptHash(), state.CategoryFood) + }) + + t.Run("committee can set tax config", func(t *testing.T) { + // 25% income tax, 5% sales tax + committeeInvoker.Invoke(t, true, "setTaxConfig", 2500, 500, treasuryAcc.ScriptHash(), state.CategoryFood) + }) + + t.Run("get tax config", func(t *testing.T) { + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array") + require.Equal(t, 4, len(arr)) // TaxConfig has 4 fields + + incomeRate, _ := arr[0].TryInteger() + require.Equal(t, int64(2500), incomeRate.Int64()) + }, "getTaxConfig") + }) +} + +// TestVTS_PayWage tests payroll with automatic tax withholding. +func TestVTS_PayWage(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + employer := e.NewAccount(t) + employee := e.NewAccount(t) + treasury := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + employerInvoker := c.WithSigners(employer) + + // Setup: Set tax config and fund employer + committeeInvoker.Invoke(t, true, "setTaxConfig", 2500, 0, treasury.ScriptHash(), 0) // 25% income tax + committeeInvoker.Invoke(t, true, "mint", employer.ScriptHash(), 10000_00000000) + + t.Run("payWage with tax withholding", func(t *testing.T) { + // Pay $5000 gross, 25% tax rate + employerInvoker.Invoke(t, true, "payWage", employer.ScriptHash(), employee.ScriptHash(), 5000_00000000, 2500) + }) + + t.Run("employer balance reduced by gross", func(t *testing.T) { + c.Invoke(t, 5000_00000000, "balanceOf", employer.ScriptHash()) + }) + + t.Run("employee receives net (75%)", func(t *testing.T) { + c.Invoke(t, 3750_00000000, "balanceOf", employee.ScriptHash()) + }) + + t.Run("treasury receives tax (25%)", func(t *testing.T) { + c.Invoke(t, 1250_00000000, "balanceOf", treasury.ScriptHash()) + }) +} + +// TestVTS_TaxRefund tests issuing tax refunds. +func TestVTS_TaxRefund(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + acc := e.NewAccount(t) + treasury := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + userInvoker := c.WithSigners(acc) + + // Setup: Set tax config + committeeInvoker.Invoke(t, true, "setTaxConfig", 2500, 0, treasury.ScriptHash(), 0) + + t.Run("non-committee cannot issue refund", func(t *testing.T) { + userInvoker.InvokeFail(t, "caller is not a committee member", "issueTaxRefund", acc.ScriptHash(), 100_00000000) + }) + + t.Run("committee can issue refund", func(t *testing.T) { + committeeInvoker.Invoke(t, true, "issueTaxRefund", acc.ScriptHash(), 100_00000000) + }) + + t.Run("refund credited to account", func(t *testing.T) { + c.Invoke(t, 100_00000000, "balanceOf", acc.ScriptHash()) + }) +} + +// TestVTS_BalanceDetails tests the balanceDetails query. +func TestVTS_BalanceDetails(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + acc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + + // Mint mixed VTS + committeeInvoker.Invoke(t, true, "mint", acc.ScriptHash(), 100_00000000) + committeeInvoker.Invoke(t, true, "mintRestricted", acc.ScriptHash(), 200_00000000, state.CategoryFood) + committeeInvoker.Invoke(t, true, "mintRestricted", acc.ScriptHash(), 300_00000000, state.CategoryShelter) + + t.Run("balanceDetails returns all categories", func(t *testing.T) { + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array") + // Should have unrestricted + categories with balances + require.GreaterOrEqual(t, len(arr), 1) + }, "balanceDetails", acc.ScriptHash()) + }) +} + +// TestVTS_TaxReportingMethods tests the tax reporting query methods. +func TestVTS_TaxReportingMethods(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + acc := e.NewAccount(t) + + // Block range for queries + startBlock := int64(0) + endBlock := int64(1000) + + t.Run("getTransactions returns array", func(t *testing.T) { + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + _, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array") + }, "getTransactions", acc.ScriptHash(), startBlock, endBlock) + }) + + t.Run("getIncomeForPeriod returns integer", func(t *testing.T) { + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + _, err := stack[0].TryInteger() + require.NoError(t, err) + }, "getIncomeForPeriod", acc.ScriptHash(), startBlock, endBlock) + }) + + t.Run("getTaxWithheld returns integer", func(t *testing.T) { + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + _, err := stack[0].TryInteger() + require.NoError(t, err) + }, "getTaxWithheld", acc.ScriptHash(), startBlock, endBlock) + }) + + t.Run("getDeductibleExpenses returns integer", func(t *testing.T) { + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + _, err := stack[0].TryInteger() + require.NoError(t, err) + }, "getDeductibleExpenses", acc.ScriptHash(), startBlock, endBlock, 0) + }) + + t.Run("getTaxSummary returns array", func(t *testing.T) { + c.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { + require.Equal(t, 1, len(stack)) + arr, ok := stack[0].Value().([]stackitem.Item) + require.True(t, ok, "expected array for tax summary") + // TaxSummary has 10 fields: Account, StartBlock, EndBlock, TotalIncome, + // TotalBenefits, TotalExpenses, DeductibleExpenses, TaxWithheld, EstimatedOwed, Balance + require.Equal(t, 10, len(arr)) + }, "getTaxSummary", acc.ScriptHash(), startBlock, endBlock) + }) +} + +// TestVTS_NegativeAmount tests that negative amounts are rejected. +func TestVTS_NegativeAmount(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + acc := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + + t.Run("mint negative fails", func(t *testing.T) { + committeeInvoker.InvokeFail(t, "amount must be positive", "mint", acc.ScriptHash(), -1) + }) + + t.Run("mintRestricted negative fails", func(t *testing.T) { + committeeInvoker.InvokeFail(t, "amount must be positive", "mintRestricted", acc.ScriptHash(), -1, state.CategoryFood) + }) +} + +// TestVTS_MultiCategoryVendor tests vendors accepting multiple categories. +func TestVTS_MultiCategoryVendor(t *testing.T) { + c := newVTSClient(t) + e := c.Executor + + customer := e.NewAccount(t) + generalStore := e.NewAccount(t) + committeeInvoker := c.WithSigners(c.Committee) + customerInvoker := c.WithSigners(customer) + + // Register vendor accepting food AND shelter + categories := state.CategoryFood | state.CategoryShelter + committeeInvoker.Invoke(t, true, "registerVendor", generalStore.ScriptHash(), "General Store", categories) + + // Mint restricted VTS for different categories + committeeInvoker.Invoke(t, true, "mintRestricted", customer.ScriptHash(), 100_00000000, state.CategoryFood) + committeeInvoker.Invoke(t, true, "mintRestricted", customer.ScriptHash(), 100_00000000, state.CategoryShelter) + + t.Run("can spend at multi-category vendor", func(t *testing.T) { + customerInvoker.Invoke(t, true, "spend", customer.ScriptHash(), generalStore.ScriptHash(), 50_00000000, nil) + // Total balance should be reduced by 50 + c.Invoke(t, 150_00000000, "balanceOf", customer.ScriptHash()) + }) + + t.Run("can spend more at same vendor", func(t *testing.T) { + customerInvoker.Invoke(t, true, "spend", customer.ScriptHash(), generalStore.ScriptHash(), 50_00000000, nil) + customerInvoker.Invoke(t, true, "spend", customer.ScriptHash(), generalStore.ScriptHash(), 50_00000000, nil) + // Total balance should be reduced by 150 + c.Invoke(t, 50_00000000, "balanceOf", customer.ScriptHash()) + }) + + t.Run("vendor has correct total", func(t *testing.T) { + c.Invoke(t, 150_00000000, "balanceOf", generalStore.ScriptHash()) + }) +} diff --git a/pkg/core/native/nativehashes/hashes.go b/pkg/core/native/nativehashes/hashes.go index 321c171..f981194 100644 --- a/pkg/core/native/nativehashes/hashes.go +++ b/pkg/core/native/nativehashes/hashes.go @@ -31,4 +31,10 @@ var ( Notary = util.Uint160{0x3b, 0xec, 0x35, 0x31, 0x11, 0x9b, 0xba, 0xd7, 0x6d, 0xd0, 0x44, 0x92, 0xb, 0xd, 0xe6, 0xc3, 0x19, 0x4f, 0xe1, 0xc1} // Treasury is a hash of native Treasury contract. Treasury = util.Uint160{0xc1, 0x3a, 0x56, 0xc9, 0x83, 0x53, 0xa7, 0xea, 0x6a, 0x32, 0x4d, 0x9a, 0x83, 0x5d, 0x1b, 0x5b, 0xf2, 0x26, 0x63, 0x15} + // PersonToken is a hash of native PersonToken contract. + PersonToken = util.Uint160{0x4, 0xaf, 0x34, 0xf1, 0xde, 0xdb, 0xa4, 0x7a, 0xd4, 0x30, 0xdf, 0xc7, 0x77, 0x1c, 0x26, 0x3a, 0x8a, 0x72, 0xa5, 0x21} + // RoleRegistry is a hash of native RoleRegistry contract. + RoleRegistry = util.Uint160{0xa9, 0x77, 0x74, 0xdc, 0x77, 0xc5, 0xcc, 0xf8, 0x1a, 0xd4, 0x90, 0xb5, 0x81, 0xb5, 0xf0, 0xc6, 0x61, 0x1, 0x20, 0x52} + // VTS is a hash of native VTS contract. + VTS = util.Uint160{0x68, 0x34, 0x5e, 0x82, 0x6d, 0x8a, 0xff, 0x41, 0x48, 0x23, 0x60, 0xd9, 0x83, 0xa3, 0xd0, 0xf9, 0xb7, 0x59, 0x36, 0x89} ) diff --git a/pkg/core/native/nativeids/ids.go b/pkg/core/native/nativeids/ids.go index 3866c9f..fc9a7b5 100644 --- a/pkg/core/native/nativeids/ids.go +++ b/pkg/core/native/nativeids/ids.go @@ -33,4 +33,6 @@ const ( PersonToken int32 = -12 // RoleRegistry is an ID of native RoleRegistry contract. RoleRegistry int32 = -13 + // VTS is an ID of native VTS contract. + VTS int32 = -14 ) diff --git a/pkg/core/native/nativenames/names.go b/pkg/core/native/nativenames/names.go index 87350a5..31d2647 100644 --- a/pkg/core/native/nativenames/names.go +++ b/pkg/core/native/nativenames/names.go @@ -15,6 +15,7 @@ const ( Treasury = "Treasury" PersonToken = "PersonToken" RoleRegistry = "RoleRegistry" + VTS = "VTS" ) // All contains the list of all native contract names ordered by the contract ID. @@ -32,6 +33,7 @@ var All = []string{ Treasury, PersonToken, RoleRegistry, + VTS, } // IsValid checks if the name is a valid native contract's name. @@ -48,5 +50,6 @@ func IsValid(name string) bool { name == StdLib || name == Treasury || name == PersonToken || - name == RoleRegistry + name == RoleRegistry || + name == VTS } diff --git a/pkg/core/native/vts.go b/pkg/core/native/vts.go new file mode 100644 index 0000000..4a7b5b2 --- /dev/null +++ b/pkg/core/native/vts.go @@ -0,0 +1,1565 @@ +package native + +import ( + "encoding/binary" + "errors" + "math/big" + + "github.com/tutus-one/tutus-chain/pkg/config" + "github.com/tutus-one/tutus-chain/pkg/core/dao" + "github.com/tutus-one/tutus-chain/pkg/core/interop" + "github.com/tutus-one/tutus-chain/pkg/core/interop/runtime" + "github.com/tutus-one/tutus-chain/pkg/core/native/nativeids" + "github.com/tutus-one/tutus-chain/pkg/core/native/nativenames" + "github.com/tutus-one/tutus-chain/pkg/core/state" + "github.com/tutus-one/tutus-chain/pkg/smartcontract" + "github.com/tutus-one/tutus-chain/pkg/smartcontract/callflag" + "github.com/tutus-one/tutus-chain/pkg/smartcontract/manifest" + "github.com/tutus-one/tutus-chain/pkg/util" + "github.com/tutus-one/tutus-chain/pkg/vm/stackitem" +) + +// VTS storage prefixes. +const ( + vtsPrefixAccount = 0x20 // address -> VTSBalance + vtsPrefixVendor = 0x21 // address -> Vendor + vtsPrefixTotalSupply = 0x11 // total supply storage + vtsPrefixTaxConfig = 0x10 // tax configuration + vtsPrefixVendorCount = 0x12 // vendor count + vtsPrefixTxRecord = 0x30 // txHash -> TransactionRecord + vtsPrefixAccountTxs = 0x31 // address + blockHeight -> []txHash index + vtsPrefixTaxWithheld = 0x32 // address + year -> cumulative tax +) + +// VTSFactor is the divisor for VTS (10^8 = 100,000,000). +const VTSFactor = 100000000 + +// VTS represents the VTS (Value Transfer System) native contract. +// VTS is programmable money with spending restrictions and automatic tax accounting. +type VTS struct { + interop.ContractMD + + NEO INEO + RoleRegistry IRoleRegistry + + symbol string + decimals int64 + factor int64 +} + +// VTSCache holds cached VTS data. +type VTSCache struct { + vendorCount int64 +} + +// Copy implements dao.NativeContractCache. +func (c *VTSCache) Copy() dao.NativeContractCache { + return &VTSCache{ + vendorCount: c.vendorCount, + } +} + +// newVTS creates a new VTS native contract. +func newVTS() *VTS { + v := &VTS{ + symbol: "VTS", + decimals: 8, + factor: VTSFactor, + } + + v.ContractMD = *interop.NewContractMD(nativenames.VTS, nativeids.VTS, func(m *manifest.Manifest, hf config.Hardfork) { + m.SupportedStandards = []string{manifest.NEP17StandardName} + }) + defer v.BuildHFSpecificMD(v.ActiveIn()) + + // NEP-17 Standard Methods + desc := NewDescriptor("symbol", smartcontract.StringType) + md := NewMethodAndPrice(v.symbolMethod, 0, callflag.NoneFlag) + v.AddMethod(md, desc) + + desc = NewDescriptor("decimals", smartcontract.IntegerType) + md = NewMethodAndPrice(v.decimalsMethod, 0, callflag.NoneFlag) + v.AddMethod(md, desc) + + desc = NewDescriptor("totalSupply", smartcontract.IntegerType) + md = NewMethodAndPrice(v.totalSupply, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + + desc = NewDescriptor("balanceOf", smartcontract.IntegerType, + manifest.NewParameter("account", smartcontract.Hash160Type)) + md = NewMethodAndPrice(v.balanceOf, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + + transferParams := []manifest.Parameter{ + manifest.NewParameter("from", smartcontract.Hash160Type), + manifest.NewParameter("to", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType), + } + desc = NewDescriptor("transfer", smartcontract.BoolType, + append(transferParams, manifest.NewParameter("data", smartcontract.AnyType))..., + ) + md = NewMethodAndPrice(v.transfer, 1<<17, callflag.States|callflag.AllowCall|callflag.AllowNotify) + md.StorageFee = 50 + v.AddMethod(md, desc) + + // Extended Balance Methods + desc = NewDescriptor("unrestrictedBalanceOf", smartcontract.IntegerType, + manifest.NewParameter("account", smartcontract.Hash160Type)) + md = NewMethodAndPrice(v.unrestrictedBalanceOf, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + + desc = NewDescriptor("restrictedBalanceOf", smartcontract.IntegerType, + manifest.NewParameter("account", smartcontract.Hash160Type), + manifest.NewParameter("category", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.restrictedBalanceOf, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + + desc = NewDescriptor("balanceDetails", smartcontract.ArrayType, + manifest.NewParameter("account", smartcontract.Hash160Type)) + md = NewMethodAndPrice(v.balanceDetails, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + + // Minting Methods (Committee-Only) + desc = NewDescriptor("mint", smartcontract.BoolType, + manifest.NewParameter("to", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.mint, 1<<17, callflag.States|callflag.AllowNotify) + v.AddMethod(md, desc) + + desc = NewDescriptor("mintRestricted", smartcontract.BoolType, + manifest.NewParameter("to", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType), + manifest.NewParameter("category", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.mintRestricted, 1<<17, callflag.States|callflag.AllowNotify) + v.AddMethod(md, desc) + + desc = NewDescriptor("burn", smartcontract.BoolType, + manifest.NewParameter("from", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.burn, 1<<17, callflag.States|callflag.AllowNotify) + v.AddMethod(md, desc) + + desc = NewDescriptor("convertToUnrestricted", smartcontract.BoolType, + manifest.NewParameter("account", smartcontract.Hash160Type), + manifest.NewParameter("category", smartcontract.IntegerType), + manifest.NewParameter("amount", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.convertToUnrestricted, 1<<17, callflag.States|callflag.AllowNotify) + v.AddMethod(md, desc) + + // Vendor Management Methods (Committee-Only) + desc = NewDescriptor("registerVendor", smartcontract.BoolType, + manifest.NewParameter("address", smartcontract.Hash160Type), + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("categories", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.registerVendor, 1<<17, callflag.States|callflag.AllowNotify) + v.AddMethod(md, desc) + + desc = NewDescriptor("updateVendor", smartcontract.BoolType, + manifest.NewParameter("address", smartcontract.Hash160Type), + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("categories", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.updateVendor, 1<<17, callflag.States|callflag.AllowNotify) + v.AddMethod(md, desc) + + desc = NewDescriptor("deactivateVendor", smartcontract.BoolType, + manifest.NewParameter("address", smartcontract.Hash160Type)) + md = NewMethodAndPrice(v.deactivateVendor, 1<<17, callflag.States|callflag.AllowNotify) + v.AddMethod(md, desc) + + desc = NewDescriptor("getVendor", smartcontract.ArrayType, + manifest.NewParameter("address", smartcontract.Hash160Type)) + md = NewMethodAndPrice(v.getVendor, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + + desc = NewDescriptor("isVendor", smartcontract.BoolType, + manifest.NewParameter("address", smartcontract.Hash160Type)) + md = NewMethodAndPrice(v.isVendor, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + + desc = NewDescriptor("getVendorCategories", smartcontract.IntegerType, + manifest.NewParameter("address", smartcontract.Hash160Type)) + md = NewMethodAndPrice(v.getVendorCategories, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + + // Spending Methods + desc = NewDescriptor("spend", smartcontract.BoolType, + manifest.NewParameter("from", smartcontract.Hash160Type), + manifest.NewParameter("vendor", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType), + manifest.NewParameter("data", smartcontract.AnyType)) + md = NewMethodAndPrice(v.spend, 1<<17, callflag.States|callflag.AllowCall|callflag.AllowNotify) + md.StorageFee = 50 + v.AddMethod(md, desc) + + desc = NewDescriptor("canSpendAt", smartcontract.BoolType, + manifest.NewParameter("account", smartcontract.Hash160Type), + manifest.NewParameter("vendor", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.canSpendAt, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + + // Tax Methods + desc = NewDescriptor("payWage", smartcontract.BoolType, + manifest.NewParameter("employer", smartcontract.Hash160Type), + manifest.NewParameter("employee", smartcontract.Hash160Type), + manifest.NewParameter("grossAmount", smartcontract.IntegerType), + manifest.NewParameter("taxRate", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.payWage, 1<<17, callflag.States|callflag.AllowNotify) + md.StorageFee = 100 + v.AddMethod(md, desc) + + desc = NewDescriptor("setTaxConfig", smartcontract.BoolType, + manifest.NewParameter("incomeRate", smartcontract.IntegerType), + manifest.NewParameter("salesRate", smartcontract.IntegerType), + manifest.NewParameter("treasuryAddress", smartcontract.Hash160Type), + manifest.NewParameter("exemptCategories", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.setTaxConfig, 1<<17, callflag.States|callflag.AllowNotify) + v.AddMethod(md, desc) + + desc = NewDescriptor("getTaxConfig", smartcontract.ArrayType) + md = NewMethodAndPrice(v.getTaxConfig, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + + desc = NewDescriptor("issueTaxRefund", smartcontract.BoolType, + manifest.NewParameter("account", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.issueTaxRefund, 1<<17, callflag.States|callflag.AllowNotify) + v.AddMethod(md, desc) + + // Tax Reporting Methods (read-only) + desc = NewDescriptor("getTransactions", smartcontract.ArrayType, + manifest.NewParameter("account", smartcontract.Hash160Type), + manifest.NewParameter("startBlock", smartcontract.IntegerType), + manifest.NewParameter("endBlock", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.getTransactions, 1<<17, callflag.ReadStates) + v.AddMethod(md, desc) + + desc = NewDescriptor("getIncomeForPeriod", smartcontract.IntegerType, + manifest.NewParameter("account", smartcontract.Hash160Type), + manifest.NewParameter("startBlock", smartcontract.IntegerType), + manifest.NewParameter("endBlock", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.getIncomeForPeriod, 1<<17, callflag.ReadStates) + v.AddMethod(md, desc) + + desc = NewDescriptor("getTaxWithheld", smartcontract.IntegerType, + manifest.NewParameter("account", smartcontract.Hash160Type), + manifest.NewParameter("startBlock", smartcontract.IntegerType), + manifest.NewParameter("endBlock", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.getTaxWithheld, 1<<17, callflag.ReadStates) + v.AddMethod(md, desc) + + desc = NewDescriptor("getDeductibleExpenses", smartcontract.IntegerType, + manifest.NewParameter("account", smartcontract.Hash160Type), + manifest.NewParameter("startBlock", smartcontract.IntegerType), + manifest.NewParameter("endBlock", smartcontract.IntegerType), + manifest.NewParameter("category", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.getDeductibleExpenses, 1<<17, callflag.ReadStates) + v.AddMethod(md, desc) + + desc = NewDescriptor("getTaxSummary", smartcontract.ArrayType, + manifest.NewParameter("account", smartcontract.Hash160Type), + manifest.NewParameter("startBlock", smartcontract.IntegerType), + manifest.NewParameter("endBlock", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.getTaxSummary, 1<<17, callflag.ReadStates) + v.AddMethod(md, desc) + + // Events + eDesc := NewEventDescriptor("Transfer", transferParams...) + eMD := NewEvent(eDesc) + v.AddEvent(eMD) + + eDesc = NewEventDescriptor("Mint", + manifest.NewParameter("to", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType), + manifest.NewParameter("category", smartcontract.IntegerType)) + eMD = NewEvent(eDesc) + v.AddEvent(eMD) + + eDesc = NewEventDescriptor("Burn", + manifest.NewParameter("from", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType)) + eMD = NewEvent(eDesc) + v.AddEvent(eMD) + + eDesc = NewEventDescriptor("Spend", + manifest.NewParameter("from", smartcontract.Hash160Type), + manifest.NewParameter("vendor", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType), + manifest.NewParameter("categoriesUsed", smartcontract.IntegerType)) + eMD = NewEvent(eDesc) + v.AddEvent(eMD) + + eDesc = NewEventDescriptor("VendorRegistered", + manifest.NewParameter("address", smartcontract.Hash160Type), + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("categories", smartcontract.IntegerType)) + eMD = NewEvent(eDesc) + v.AddEvent(eMD) + + eDesc = NewEventDescriptor("VendorUpdated", + manifest.NewParameter("address", smartcontract.Hash160Type), + manifest.NewParameter("name", smartcontract.StringType), + manifest.NewParameter("categories", smartcontract.IntegerType)) + eMD = NewEvent(eDesc) + v.AddEvent(eMD) + + eDesc = NewEventDescriptor("VendorDeactivated", + manifest.NewParameter("address", smartcontract.Hash160Type)) + eMD = NewEvent(eDesc) + v.AddEvent(eMD) + + eDesc = NewEventDescriptor("TaxWithheld", + manifest.NewParameter("from", smartcontract.Hash160Type), + manifest.NewParameter("to", smartcontract.Hash160Type), + manifest.NewParameter("grossAmount", smartcontract.IntegerType), + manifest.NewParameter("taxAmount", smartcontract.IntegerType), + manifest.NewParameter("taxRate", smartcontract.IntegerType)) + eMD = NewEvent(eDesc) + v.AddEvent(eMD) + + eDesc = NewEventDescriptor("TaxRefunded", + manifest.NewParameter("account", smartcontract.Hash160Type), + manifest.NewParameter("amount", smartcontract.IntegerType)) + eMD = NewEvent(eDesc) + v.AddEvent(eMD) + + eDesc = NewEventDescriptor("ConvertedToUnrestricted", + manifest.NewParameter("account", smartcontract.Hash160Type), + manifest.NewParameter("category", smartcontract.IntegerType), + manifest.NewParameter("amount", smartcontract.IntegerType)) + eMD = NewEvent(eDesc) + v.AddEvent(eMD) + + return v +} + +// Metadata returns VTS contract metadata. +func (v *VTS) Metadata() *interop.ContractMD { + return &v.ContractMD +} + +// Initialize initializes VTS contract at genesis. +func (v *VTS) Initialize(ic *interop.Context, hf *config.Hardfork, newMD *interop.HFSpecificContractMD) error { + if hf != v.ActiveIn() { + return nil + } + + // Initialize with zero total supply (VTS is minted on-demand) + v.putTotalSupply(ic.DAO, big.NewInt(0)) + + // Initialize vendor count + v.putVendorCount(ic.DAO, 0) + + // Initialize default tax config (no taxes by default) + cfg := &state.TaxConfig{ + DefaultIncomeRate: 0, + DefaultSalesRate: 0, + TreasuryAddress: util.Uint160{}, + ExemptCategories: 0, + } + v.putTaxConfig(ic.DAO, cfg) + + return nil +} + +// InitializeCache implements the Contract interface. +func (v *VTS) InitializeCache(_ interop.IsHardforkEnabled, blockHeight uint32, d *dao.Simple) error { + cache := &VTSCache{ + vendorCount: v.getVendorCountInternal(d), + } + d.SetCache(v.ID, cache) + return nil +} + +// OnPersist implements the Contract interface. +func (v *VTS) OnPersist(ic *interop.Context) error { + return nil +} + +// PostPersist implements the Contract interface. +func (v *VTS) PostPersist(ic *interop.Context) error { + return nil +} + +// ActiveIn implements the Contract interface. +func (v *VTS) ActiveIn() *config.Hardfork { + return nil +} + +// checkCommittee verifies the caller has committee privileges. +func (v *VTS) checkCommittee(ic *interop.Context) bool { + if v.RoleRegistry != nil { + return v.RoleRegistry.CheckCommittee(ic) + } + if v.NEO != nil { + return v.NEO.CheckCommittee(ic) + } + return false +} + +// ============ Storage Key Helpers ============ + +func (v *VTS) makeAccountKey(h util.Uint160) []byte { + return makeUint160Key(vtsPrefixAccount, h) +} + +func (v *VTS) makeVendorKey(h util.Uint160) []byte { + return makeUint160Key(vtsPrefixVendor, h) +} + +var vtsTotalSupplyKey = []byte{vtsPrefixTotalSupply} +var vtsVendorCountKey = []byte{vtsPrefixVendorCount} +var vtsTaxConfigKey = []byte{vtsPrefixTaxConfig} + +// ============ NEP-17 Standard Methods ============ + +func (v *VTS) symbolMethod(_ *interop.Context, _ []stackitem.Item) stackitem.Item { + return stackitem.NewByteArray([]byte(v.symbol)) +} + +func (v *VTS) decimalsMethod(_ *interop.Context, _ []stackitem.Item) stackitem.Item { + return stackitem.NewBigInteger(big.NewInt(v.decimals)) +} + +func (v *VTS) totalSupply(ic *interop.Context, _ []stackitem.Item) stackitem.Item { + supply := v.getTotalSupplyInternal(ic.DAO) + return stackitem.NewBigInteger(supply) +} + +func (v *VTS) getTotalSupplyInternal(d *dao.Simple) *big.Int { + si := d.GetStorageItem(v.ID, vtsTotalSupplyKey) + if si == nil { + return big.NewInt(0) + } + bal, err := state.NEP17BalanceFromBytes(si) + if err != nil { + return big.NewInt(0) + } + return &bal.Balance +} + +func (v *VTS) putTotalSupply(d *dao.Simple, supply *big.Int) { + bal := &state.NEP17Balance{Balance: *supply} + d.PutStorageItem(v.ID, vtsTotalSupplyKey, bal.Bytes(nil)) +} + +func (v *VTS) balanceOf(ic *interop.Context, args []stackitem.Item) stackitem.Item { + h := toUint160(args[0]) + balance := v.getBalanceInternal(ic.DAO, h) + return stackitem.NewBigInteger(balance.Total()) +} + +func (v *VTS) getBalanceInternal(d *dao.Simple, h util.Uint160) *state.VTSBalance { + key := v.makeAccountKey(h) + si := d.GetStorageItem(v.ID, key) + if si == nil { + return state.NewVTSBalance() + } + + item, err := stackitem.Deserialize(si) + if err != nil { + return state.NewVTSBalance() + } + + bal := state.NewVTSBalance() + if err := bal.FromStackItem(item); err != nil { + return state.NewVTSBalance() + } + return bal +} + +func (v *VTS) putBalance(d *dao.Simple, h util.Uint160, bal *state.VTSBalance) error { + key := v.makeAccountKey(h) + + // If balance is zero, delete the entry + if bal.Total().Sign() == 0 { + d.DeleteStorageItem(v.ID, key) + return nil + } + + item, err := bal.ToStackItem() + if err != nil { + return err + } + data, err := stackitem.Serialize(item) + if err != nil { + return err + } + d.PutStorageItem(v.ID, key, data) + return nil +} + +func (v *VTS) transfer(ic *interop.Context, args []stackitem.Item) stackitem.Item { + from := toUint160(args[0]) + to := toUint160(args[1]) + amount := toBigInt(args[2]) + + // Transfer only unrestricted balance + err := v.transferUnrestricted(ic, from, to, amount, args[3]) + return stackitem.NewBool(err == nil) +} + +func (v *VTS) transferUnrestricted(ic *interop.Context, from, to util.Uint160, amount *big.Int, data stackitem.Item) error { + if amount.Sign() < 0 { + return errors.New("negative amount") + } + + // Check witness + caller := ic.VM.GetCallingScriptHash() + if !from.Equals(caller) { + ok, err := checkWitness(ic, from) + if err != nil || !ok { + return errors.New("invalid signature") + } + } + + if amount.Sign() == 0 { + v.emitTransfer(ic, &from, &to, amount) + return nil + } + + // Get sender balance + fromBal := v.getBalanceInternal(ic.DAO, from) + if fromBal.Unrestricted.Cmp(amount) < 0 { + return errors.New("insufficient unrestricted funds") + } + + // Update sender balance + fromBal.Unrestricted.Sub(&fromBal.Unrestricted, amount) + if err := v.putBalance(ic.DAO, from, fromBal); err != nil { + return err + } + + // Update recipient balance + toBal := v.getBalanceInternal(ic.DAO, to) + toBal.Unrestricted.Add(&toBal.Unrestricted, amount) + if err := v.putBalance(ic.DAO, to, toBal); err != nil { + return err + } + + v.emitTransfer(ic, &from, &to, amount) + return nil +} + +func (v *VTS) emitTransfer(ic *interop.Context, from, to *util.Uint160, amount *big.Int) { + ic.AddNotification(v.Hash, "Transfer", stackitem.NewArray([]stackitem.Item{ + addrToStackItem(from), + addrToStackItem(to), + stackitem.NewBigInteger(amount), + })) +} + +// ============ Extended Balance Methods ============ + +func (v *VTS) unrestrictedBalanceOf(ic *interop.Context, args []stackitem.Item) stackitem.Item { + h := toUint160(args[0]) + bal := v.getBalanceInternal(ic.DAO, h) + return stackitem.NewBigInteger(&bal.Unrestricted) +} + +func (v *VTS) restrictedBalanceOf(ic *interop.Context, args []stackitem.Item) stackitem.Item { + h := toUint160(args[0]) + category := toUint8(args[1]) + bal := v.getBalanceInternal(ic.DAO, h) + + if amt, ok := bal.Restricted[category]; ok { + return stackitem.NewBigInteger(amt) + } + return stackitem.NewBigInteger(big.NewInt(0)) +} + +func (v *VTS) balanceDetails(ic *interop.Context, args []stackitem.Item) stackitem.Item { + h := toUint160(args[0]) + bal := v.getBalanceInternal(ic.DAO, h) + + // Return: [unrestricted, [[category, amount], ...]] + restricted := make([]stackitem.Item, 0, len(bal.Restricted)) + for cat, amt := range bal.Restricted { + if amt.Sign() > 0 { + restricted = append(restricted, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(cat))), + stackitem.NewBigInteger(amt), + })) + } + } + + return stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(&bal.Unrestricted), + stackitem.NewArray(restricted), + }) +} + +// ============ Minting Methods ============ + +func (v *VTS) mint(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if !v.checkCommittee(ic) { + panic("caller is not a committee member") + } + + to := toUint160(args[0]) + amount := toBigInt(args[1]) + + if amount.Sign() <= 0 { + panic("amount must be positive") + } + + v.mintUnrestricted(ic, to, amount) + return stackitem.NewBool(true) +} + +func (v *VTS) mintUnrestricted(ic *interop.Context, to util.Uint160, amount *big.Int) { + bal := v.getBalanceInternal(ic.DAO, to) + bal.Unrestricted.Add(&bal.Unrestricted, amount) + v.putBalance(ic.DAO, to, bal) + + // Update total supply + supply := v.getTotalSupplyInternal(ic.DAO) + supply.Add(supply, amount) + v.putTotalSupply(ic.DAO, supply) + + v.emitTransfer(ic, nil, &to, amount) + ic.AddNotification(v.Hash, "Mint", stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(to.BytesBE()), + stackitem.NewBigInteger(amount), + stackitem.NewBigInteger(big.NewInt(0)), // Category 0 = unrestricted + })) +} + +func (v *VTS) mintRestricted(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if !v.checkCommittee(ic) { + panic("caller is not a committee member") + } + + to := toUint160(args[0]) + amount := toBigInt(args[1]) + category := toUint8(args[2]) + + if amount.Sign() <= 0 { + panic("amount must be positive") + } + if category == state.CategoryUnrestricted { + panic("use mint() for unrestricted tokens") + } + + v.mintRestrictedInternal(ic, to, amount, category) + return stackitem.NewBool(true) +} + +func (v *VTS) mintRestrictedInternal(ic *interop.Context, to util.Uint160, amount *big.Int, category uint8) { + bal := v.getBalanceInternal(ic.DAO, to) + if bal.Restricted == nil { + bal.Restricted = make(map[uint8]*big.Int) + } + if bal.Restricted[category] == nil { + bal.Restricted[category] = big.NewInt(0) + } + bal.Restricted[category].Add(bal.Restricted[category], amount) + v.putBalance(ic.DAO, to, bal) + + // Update total supply + supply := v.getTotalSupplyInternal(ic.DAO) + supply.Add(supply, amount) + v.putTotalSupply(ic.DAO, supply) + + v.emitTransfer(ic, nil, &to, amount) + ic.AddNotification(v.Hash, "Mint", stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(to.BytesBE()), + stackitem.NewBigInteger(amount), + stackitem.NewBigInteger(big.NewInt(int64(category))), + })) +} + +func (v *VTS) burn(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if !v.checkCommittee(ic) { + panic("caller is not a committee member") + } + + from := toUint160(args[0]) + amount := toBigInt(args[1]) + + if amount.Sign() <= 0 { + panic("amount must be positive") + } + + bal := v.getBalanceInternal(ic.DAO, from) + if bal.Unrestricted.Cmp(amount) < 0 { + panic("insufficient unrestricted funds") + } + + bal.Unrestricted.Sub(&bal.Unrestricted, amount) + v.putBalance(ic.DAO, from, bal) + + // Update total supply + supply := v.getTotalSupplyInternal(ic.DAO) + supply.Sub(supply, amount) + v.putTotalSupply(ic.DAO, supply) + + v.emitTransfer(ic, &from, nil, amount) + ic.AddNotification(v.Hash, "Burn", stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(from.BytesBE()), + stackitem.NewBigInteger(amount), + })) + + return stackitem.NewBool(true) +} + +func (v *VTS) convertToUnrestricted(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if !v.checkCommittee(ic) { + panic("caller is not a committee member") + } + + account := toUint160(args[0]) + category := toUint8(args[1]) + amount := toBigInt(args[2]) + + if amount.Sign() <= 0 { + panic("amount must be positive") + } + + bal := v.getBalanceInternal(ic.DAO, account) + if bal.Restricted[category] == nil || bal.Restricted[category].Cmp(amount) < 0 { + panic("insufficient restricted funds in category") + } + + // Move from restricted to unrestricted + bal.Restricted[category].Sub(bal.Restricted[category], amount) + if bal.Restricted[category].Sign() == 0 { + delete(bal.Restricted, category) + } + bal.Unrestricted.Add(&bal.Unrestricted, amount) + + v.putBalance(ic.DAO, account, bal) + + ic.AddNotification(v.Hash, "ConvertedToUnrestricted", stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(account.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(category))), + stackitem.NewBigInteger(amount), + })) + + return stackitem.NewBool(true) +} + +// ============ Vendor Management ============ + +func (v *VTS) registerVendor(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if !v.checkCommittee(ic) { + panic("caller is not a committee member") + } + + address := toUint160(args[0]) + name := toString(args[1]) + categories := toUint8(args[2]) + + if len(name) == 0 || len(name) > 64 { + panic("invalid vendor name") + } + + // Check if vendor already exists + if v.getVendorInternal(ic.DAO, address) != nil { + panic("vendor already registered") + } + + vendor := &state.Vendor{ + Address: address, + Name: name, + Categories: categories, + RegisteredAt: ic.Block.Index, + RegisteredBy: ic.VM.GetCallingScriptHash(), + Active: true, + } + + v.putVendor(ic.DAO, vendor) + + // Increment vendor count + count := v.getVendorCountInternal(ic.DAO) + v.putVendorCount(ic.DAO, count+1) + + ic.AddNotification(v.Hash, "VendorRegistered", stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(address.BytesBE()), + stackitem.NewByteArray([]byte(name)), + stackitem.NewBigInteger(big.NewInt(int64(categories))), + })) + + return stackitem.NewBool(true) +} + +func (v *VTS) updateVendor(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if !v.checkCommittee(ic) { + panic("caller is not a committee member") + } + + address := toUint160(args[0]) + name := toString(args[1]) + categories := toUint8(args[2]) + + if len(name) == 0 || len(name) > 64 { + panic("invalid vendor name") + } + + vendor := v.getVendorInternal(ic.DAO, address) + if vendor == nil { + panic("vendor not found") + } + + vendor.Name = name + vendor.Categories = categories + v.putVendor(ic.DAO, vendor) + + ic.AddNotification(v.Hash, "VendorUpdated", stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(address.BytesBE()), + stackitem.NewByteArray([]byte(name)), + stackitem.NewBigInteger(big.NewInt(int64(categories))), + })) + + return stackitem.NewBool(true) +} + +func (v *VTS) deactivateVendor(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if !v.checkCommittee(ic) { + panic("caller is not a committee member") + } + + address := toUint160(args[0]) + + vendor := v.getVendorInternal(ic.DAO, address) + if vendor == nil { + panic("vendor not found") + } + + vendor.Active = false + v.putVendor(ic.DAO, vendor) + + ic.AddNotification(v.Hash, "VendorDeactivated", stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(address.BytesBE()), + })) + + return stackitem.NewBool(true) +} + +func (v *VTS) getVendor(ic *interop.Context, args []stackitem.Item) stackitem.Item { + address := toUint160(args[0]) + vendor := v.getVendorInternal(ic.DAO, address) + if vendor == nil { + return stackitem.Null{} + } + + item, _ := vendor.ToStackItem() + return item +} + +func (v *VTS) getVendorInternal(d *dao.Simple, address util.Uint160) *state.Vendor { + key := v.makeVendorKey(address) + si := d.GetStorageItem(v.ID, key) + if si == nil { + return nil + } + + item, err := stackitem.Deserialize(si) + if err != nil { + return nil + } + + vendor := &state.Vendor{} + if err := vendor.FromStackItem(item); err != nil { + return nil + } + return vendor +} + +func (v *VTS) putVendor(d *dao.Simple, vendor *state.Vendor) { + key := v.makeVendorKey(vendor.Address) + item, _ := vendor.ToStackItem() + data, _ := stackitem.Serialize(item) + d.PutStorageItem(v.ID, key, data) +} + +func (v *VTS) isVendor(ic *interop.Context, args []stackitem.Item) stackitem.Item { + address := toUint160(args[0]) + vendor := v.getVendorInternal(ic.DAO, address) + return stackitem.NewBool(vendor != nil && vendor.Active) +} + +func (v *VTS) getVendorCategories(ic *interop.Context, args []stackitem.Item) stackitem.Item { + address := toUint160(args[0]) + vendor := v.getVendorInternal(ic.DAO, address) + if vendor == nil { + return stackitem.NewBigInteger(big.NewInt(0)) + } + return stackitem.NewBigInteger(big.NewInt(int64(vendor.Categories))) +} + +func (v *VTS) getVendorCountInternal(d *dao.Simple) int64 { + si := d.GetStorageItem(v.ID, vtsVendorCountKey) + if si == nil { + return 0 + } + return int64(binary.BigEndian.Uint64(si)) +} + +func (v *VTS) putVendorCount(d *dao.Simple, count int64) { + data := make([]byte, 8) + binary.BigEndian.PutUint64(data, uint64(count)) + d.PutStorageItem(v.ID, vtsVendorCountKey, data) +} + +// ============ Spending Logic ============ + +func (v *VTS) spend(ic *interop.Context, args []stackitem.Item) stackitem.Item { + from := toUint160(args[0]) + vendor := toUint160(args[1]) + amount := toBigInt(args[2]) + data := args[3] + + if amount.Sign() <= 0 { + panic("amount must be positive") + } + + // Check witness for sender + caller := ic.VM.GetCallingScriptHash() + if !from.Equals(caller) { + ok, err := checkWitness(ic, from) + if err != nil || !ok { + panic("invalid signature") + } + } + + // Verify vendor is registered and active + vendorInfo := v.getVendorInternal(ic.DAO, vendor) + if vendorInfo == nil || !vendorInfo.Active { + panic("invalid or inactive vendor") + } + + // Get sender's balance + fromBal := v.getBalanceInternal(ic.DAO, from) + + // Try to spend from matching restricted categories first + remaining := new(big.Int).Set(amount) + var categoriesUsed uint8 = 0 + + for category, catBal := range fromBal.Restricted { + if remaining.Sign() <= 0 { + break + } + // Check if vendor accepts this category + if vendorInfo.Categories&category != 0 { + toSpend := new(big.Int) + if catBal.Cmp(remaining) >= 0 { + toSpend.Set(remaining) + } else { + toSpend.Set(catBal) + } + catBal.Sub(catBal, toSpend) + if catBal.Sign() == 0 { + delete(fromBal.Restricted, category) + } + remaining.Sub(remaining, toSpend) + categoriesUsed |= category + } + } + + // If still remaining, use unrestricted balance + if remaining.Sign() > 0 { + if fromBal.Unrestricted.Cmp(remaining) < 0 { + panic("insufficient funds") + } + fromBal.Unrestricted.Sub(&fromBal.Unrestricted, remaining) + } + + // Save sender balance + v.putBalance(ic.DAO, from, fromBal) + + // Credit vendor with unrestricted balance + vendorBal := v.getBalanceInternal(ic.DAO, vendor) + vendorBal.Unrestricted.Add(&vendorBal.Unrestricted, amount) + v.putBalance(ic.DAO, vendor, vendorBal) + + // Emit events + v.emitTransfer(ic, &from, &vendor, amount) + ic.AddNotification(v.Hash, "Spend", stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(from.BytesBE()), + stackitem.NewByteArray(vendor.BytesBE()), + stackitem.NewBigInteger(amount), + stackitem.NewBigInteger(big.NewInt(int64(categoriesUsed))), + })) + + _ = data // data parameter for compatibility + return stackitem.NewBool(true) +} + +func (v *VTS) canSpendAt(ic *interop.Context, args []stackitem.Item) stackitem.Item { + account := toUint160(args[0]) + vendor := toUint160(args[1]) + amount := toBigInt(args[2]) + + if amount.Sign() <= 0 { + return stackitem.NewBool(false) + } + + // Check vendor + vendorInfo := v.getVendorInternal(ic.DAO, vendor) + if vendorInfo == nil || !vendorInfo.Active { + return stackitem.NewBool(false) + } + + // Get balance + bal := v.getBalanceInternal(ic.DAO, account) + + // Calculate available funds + available := new(big.Int).Set(&bal.Unrestricted) + for category, catBal := range bal.Restricted { + if vendorInfo.Categories&category != 0 { + available.Add(available, catBal) + } + } + + return stackitem.NewBool(available.Cmp(amount) >= 0) +} + +// ============ Tax Accounting ============ + +func (v *VTS) payWage(ic *interop.Context, args []stackitem.Item) stackitem.Item { + employer := toUint160(args[0]) + employee := toUint160(args[1]) + grossAmount := toBigInt(args[2]) + taxRate := toUint64(args[3]) // basis points (e.g., 2500 = 25%) + + if grossAmount.Sign() <= 0 { + panic("gross amount must be positive") + } + if taxRate > 10000 { + panic("tax rate cannot exceed 100%") + } + + // Check witness for employer + caller := ic.VM.GetCallingScriptHash() + if !employer.Equals(caller) { + ok, err := checkWitness(ic, employer) + if err != nil || !ok { + panic("invalid signature") + } + } + + // Get employer balance + employerBal := v.getBalanceInternal(ic.DAO, employer) + if employerBal.Unrestricted.Cmp(grossAmount) < 0 { + panic("insufficient funds") + } + + // Calculate tax and net amounts + taxAmount := new(big.Int).Mul(grossAmount, big.NewInt(int64(taxRate))) + taxAmount.Div(taxAmount, big.NewInt(10000)) + netAmount := new(big.Int).Sub(grossAmount, taxAmount) + + // Get tax config for treasury address + taxConfig := v.getTaxConfigInternal(ic.DAO) + + // Deduct from employer + employerBal.Unrestricted.Sub(&employerBal.Unrestricted, grossAmount) + v.putBalance(ic.DAO, employer, employerBal) + + // Credit employee (net) + employeeBal := v.getBalanceInternal(ic.DAO, employee) + employeeBal.Unrestricted.Add(&employeeBal.Unrestricted, netAmount) + v.putBalance(ic.DAO, employee, employeeBal) + + // Credit treasury (tax) + if taxAmount.Sign() > 0 && !taxConfig.TreasuryAddress.Equals(util.Uint160{}) { + treasuryBal := v.getBalanceInternal(ic.DAO, taxConfig.TreasuryAddress) + treasuryBal.Unrestricted.Add(&treasuryBal.Unrestricted, taxAmount) + v.putBalance(ic.DAO, taxConfig.TreasuryAddress, treasuryBal) + } + + // Store transaction record for tax reporting + record := &state.TransactionRecord{ + TxHash: ic.Tx.Hash(), + BlockHeight: ic.BlockHeight(), + From: employer, + To: employee, + Amount: *grossAmount, + TxType: state.TxTypeIncome, + Category: state.CategoryUnrestricted, + TaxWithheld: *taxAmount, + TaxRate: uint16(taxRate), + } + v.storeTransactionRecord(ic.DAO, record) + + // Emit events + v.emitTransfer(ic, &employer, &employee, netAmount) + if taxAmount.Sign() > 0 && !taxConfig.TreasuryAddress.Equals(util.Uint160{}) { + v.emitTransfer(ic, &employer, &taxConfig.TreasuryAddress, taxAmount) + } + ic.AddNotification(v.Hash, "TaxWithheld", stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(employer.BytesBE()), + stackitem.NewByteArray(employee.BytesBE()), + stackitem.NewBigInteger(grossAmount), + stackitem.NewBigInteger(taxAmount), + stackitem.NewBigInteger(big.NewInt(int64(taxRate))), + })) + + return stackitem.NewBool(true) +} + +func (v *VTS) setTaxConfig(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if !v.checkCommittee(ic) { + panic("caller is not a committee member") + } + + incomeRate := uint16(toUint64(args[0])) + salesRate := uint16(toUint64(args[1])) + treasuryAddress := toUint160(args[2]) + exemptCategories := toUint8(args[3]) + + if incomeRate > 10000 || salesRate > 10000 { + panic("rate cannot exceed 100%") + } + + cfg := &state.TaxConfig{ + DefaultIncomeRate: incomeRate, + DefaultSalesRate: salesRate, + TreasuryAddress: treasuryAddress, + ExemptCategories: exemptCategories, + } + v.putTaxConfig(ic.DAO, cfg) + + return stackitem.NewBool(true) +} + +func (v *VTS) getTaxConfig(ic *interop.Context, _ []stackitem.Item) stackitem.Item { + cfg := v.getTaxConfigInternal(ic.DAO) + item, _ := cfg.ToStackItem() + return item +} + +func (v *VTS) getTaxConfigInternal(d *dao.Simple) *state.TaxConfig { + si := d.GetStorageItem(v.ID, vtsTaxConfigKey) + if si == nil { + return &state.TaxConfig{} + } + + item, err := stackitem.Deserialize(si) + if err != nil { + return &state.TaxConfig{} + } + + cfg := &state.TaxConfig{} + if err := cfg.FromStackItem(item); err != nil { + return &state.TaxConfig{} + } + return cfg +} + +func (v *VTS) putTaxConfig(d *dao.Simple, cfg *state.TaxConfig) { + item, _ := cfg.ToStackItem() + data, _ := stackitem.Serialize(item) + d.PutStorageItem(v.ID, vtsTaxConfigKey, data) +} + +func (v *VTS) issueTaxRefund(ic *interop.Context, args []stackitem.Item) stackitem.Item { + if !v.checkCommittee(ic) { + panic("caller is not a committee member") + } + + account := toUint160(args[0]) + amount := toBigInt(args[1]) + + if amount.Sign() <= 0 { + panic("amount must be positive") + } + + // Mint new VTS as refund (comes from treasury/system) + v.mintUnrestricted(ic, account, amount) + + ic.AddNotification(v.Hash, "TaxRefunded", stackitem.NewArray([]stackitem.Item{ + stackitem.NewByteArray(account.BytesBE()), + stackitem.NewBigInteger(amount), + })) + + return stackitem.NewBool(true) +} + +// ============ Tax Reporting Methods ============ + +// makeTxRecordKey creates a storage key for a transaction record. +func (v *VTS) makeTxRecordKey(blockHeight uint32, txIndex uint32) []byte { + key := make([]byte, 9) + key[0] = vtsPrefixTxRecord + binary.BigEndian.PutUint32(key[1:5], blockHeight) + binary.BigEndian.PutUint32(key[5:9], txIndex) + return key +} + +// makeAccountTxIndexKey creates a storage key for an account's transaction index. +func (v *VTS) makeAccountTxIndexKey(account util.Uint160, blockHeight uint32) []byte { + key := make([]byte, 25) + key[0] = vtsPrefixAccountTxs + copy(key[1:21], account.BytesBE()) + binary.BigEndian.PutUint32(key[21:25], blockHeight) + return key +} + +// makeTaxWithheldKey creates a storage key for cumulative tax withheld. +func (v *VTS) makeTaxWithheldKey(account util.Uint160, blockHeight uint32) []byte { + key := make([]byte, 25) + key[0] = vtsPrefixTaxWithheld + copy(key[1:21], account.BytesBE()) + binary.BigEndian.PutUint32(key[21:25], blockHeight) + return key +} + +// storeTransactionRecord stores a transaction record and updates indexes. +func (v *VTS) storeTransactionRecord(d *dao.Simple, record *state.TransactionRecord) { + // Get current tx index for this block + txIndex := v.getNextTxIndex(d, record.BlockHeight) + + // Store the record + key := v.makeTxRecordKey(record.BlockHeight, txIndex) + item, _ := record.ToStackItem() + data, err := stackitem.Serialize(item) + if err != nil { + panic("failed to serialize transaction record") + } + d.PutStorageItem(v.ID, key, data) + + // Update account indexes for both from and to + v.addAccountTxIndex(d, record.From, record.BlockHeight, txIndex) + if !record.To.Equals(record.From) { + v.addAccountTxIndex(d, record.To, record.BlockHeight, txIndex) + } + + // Update cumulative tax withheld for recipient (income tax) + if record.TxType == state.TxTypeIncome && record.TaxWithheld.Sign() > 0 { + v.addCumulativeTaxWithheld(d, record.To, record.BlockHeight, &record.TaxWithheld) + } + + // Increment tx count for this block + v.putTxCount(d, record.BlockHeight, txIndex+1) +} + +// getNextTxIndex returns the next transaction index for a block. +func (v *VTS) getNextTxIndex(d *dao.Simple, blockHeight uint32) uint32 { + return v.getTxCount(d, blockHeight) +} + +// makeTxCountKey creates a storage key for transaction count per block. +func (v *VTS) makeTxCountKey(blockHeight uint32) []byte { + key := make([]byte, 5) + key[0] = vtsPrefixTxRecord + binary.BigEndian.PutUint32(key[1:5], blockHeight) + return key +} + +// getTxCount returns the transaction count for a block. +func (v *VTS) getTxCount(d *dao.Simple, blockHeight uint32) uint32 { + key := v.makeTxCountKey(blockHeight) + si := d.GetStorageItem(v.ID, key) + if si == nil || len(si) < 4 { + return 0 + } + return binary.BigEndian.Uint32(si) +} + +// putTxCount stores the transaction count for a block. +func (v *VTS) putTxCount(d *dao.Simple, blockHeight uint32, count uint32) { + key := v.makeTxCountKey(blockHeight) + data := make([]byte, 4) + binary.BigEndian.PutUint32(data, count) + d.PutStorageItem(v.ID, key, data) +} + +// addAccountTxIndex adds a transaction index to an account's list. +func (v *VTS) addAccountTxIndex(d *dao.Simple, account util.Uint160, blockHeight uint32, txIndex uint32) { + key := v.makeAccountTxIndexKey(account, blockHeight) + existing := d.GetStorageItem(v.ID, key) + + // Append new index + newData := make([]byte, len(existing)+4) + copy(newData, existing) + binary.BigEndian.PutUint32(newData[len(existing):], txIndex) + d.PutStorageItem(v.ID, key, newData) +} + +// addCumulativeTaxWithheld adds to the cumulative tax withheld for an account. +func (v *VTS) addCumulativeTaxWithheld(d *dao.Simple, account util.Uint160, blockHeight uint32, amount *big.Int) { + key := v.makeTaxWithheldKey(account, blockHeight) + existing := d.GetStorageItem(v.ID, key) + + cumulative := big.NewInt(0) + if len(existing) > 0 { + cumulative.SetBytes(existing) + } + cumulative.Add(cumulative, amount) + + d.PutStorageItem(v.ID, key, cumulative.Bytes()) +} + +// getTransactions returns transactions for an account in a block range. +func (v *VTS) getTransactions(ic *interop.Context, args []stackitem.Item) stackitem.Item { + account := toUint160(args[0]) + startBlock := uint32(toUint64(args[1])) + endBlock := uint32(toUint64(args[2])) + + if endBlock < startBlock { + panic("endBlock must be >= startBlock") + } + + // Limit range to prevent DoS + maxRange := uint32(10000) + if endBlock-startBlock > maxRange { + panic("block range too large") + } + + var records []stackitem.Item + for block := startBlock; block <= endBlock; block++ { + // Get transaction indexes for this account at this block + key := v.makeAccountTxIndexKey(account, block) + indexData := ic.DAO.GetStorageItem(v.ID, key) + + // Parse indexes + for i := 0; i+4 <= len(indexData); i += 4 { + txIndex := binary.BigEndian.Uint32(indexData[i : i+4]) + + // Get the transaction record + recordKey := v.makeTxRecordKey(block, txIndex) + recordData := ic.DAO.GetStorageItem(v.ID, recordKey) + if len(recordData) > 0 { + item, err := stackitem.Deserialize(recordData) + if err == nil { + records = append(records, item) + } + } + } + } + + return stackitem.NewArray(records) +} + +// getIncomeForPeriod returns total taxable income for an account in a block range. +func (v *VTS) getIncomeForPeriod(ic *interop.Context, args []stackitem.Item) stackitem.Item { + account := toUint160(args[0]) + startBlock := uint32(toUint64(args[1])) + endBlock := uint32(toUint64(args[2])) + + if endBlock < startBlock { + panic("endBlock must be >= startBlock") + } + + totalIncome := big.NewInt(0) + for block := startBlock; block <= endBlock; block++ { + key := v.makeAccountTxIndexKey(account, block) + indexData := ic.DAO.GetStorageItem(v.ID, key) + + for i := 0; i+4 <= len(indexData); i += 4 { + txIndex := binary.BigEndian.Uint32(indexData[i : i+4]) + recordKey := v.makeTxRecordKey(block, txIndex) + recordData := ic.DAO.GetStorageItem(v.ID, recordKey) + + if len(recordData) > 0 { + item, err := stackitem.Deserialize(recordData) + if err == nil { + var record state.TransactionRecord + if record.FromStackItem(item) == nil { + // Only count income where this account is the recipient + if record.To.Equals(account) && record.TxType == state.TxTypeIncome { + totalIncome.Add(totalIncome, &record.Amount) + } + } + } + } + } + } + + return stackitem.NewBigInteger(totalIncome) +} + +// getTaxWithheld returns total tax withheld for an account in a block range. +func (v *VTS) getTaxWithheld(ic *interop.Context, args []stackitem.Item) stackitem.Item { + account := toUint160(args[0]) + startBlock := uint32(toUint64(args[1])) + endBlock := uint32(toUint64(args[2])) + + if endBlock < startBlock { + panic("endBlock must be >= startBlock") + } + + totalTax := big.NewInt(0) + for block := startBlock; block <= endBlock; block++ { + key := v.makeTaxWithheldKey(account, block) + data := ic.DAO.GetStorageItem(v.ID, key) + if len(data) > 0 { + blockTax := new(big.Int).SetBytes(data) + totalTax.Add(totalTax, blockTax) + } + } + + return stackitem.NewBigInteger(totalTax) +} + +// getDeductibleExpenses returns total deductible expenses for an account in a block range. +func (v *VTS) getDeductibleExpenses(ic *interop.Context, args []stackitem.Item) stackitem.Item { + account := toUint160(args[0]) + startBlock := uint32(toUint64(args[1])) + endBlock := uint32(toUint64(args[2])) + category := toUint8(args[3]) + + if endBlock < startBlock { + panic("endBlock must be >= startBlock") + } + + totalExpenses := big.NewInt(0) + for block := startBlock; block <= endBlock; block++ { + key := v.makeAccountTxIndexKey(account, block) + indexData := ic.DAO.GetStorageItem(v.ID, key) + + for i := 0; i+4 <= len(indexData); i += 4 { + txIndex := binary.BigEndian.Uint32(indexData[i : i+4]) + recordKey := v.makeTxRecordKey(block, txIndex) + recordData := ic.DAO.GetStorageItem(v.ID, recordKey) + + if len(recordData) > 0 { + item, err := stackitem.Deserialize(recordData) + if err == nil { + var record state.TransactionRecord + if record.FromStackItem(item) == nil { + // Count expenses where this account is the sender + if record.From.Equals(account) && record.TxType == state.TxTypeExpense { + // Filter by category if specified + if category == 0 || record.Category == category { + totalExpenses.Add(totalExpenses, &record.Amount) + } + } + } + } + } + } + } + + return stackitem.NewBigInteger(totalExpenses) +} + +// getTaxSummary returns a tax summary for an account in a block range. +func (v *VTS) getTaxSummary(ic *interop.Context, args []stackitem.Item) stackitem.Item { + account := toUint160(args[0]) + startBlock := uint32(toUint64(args[1])) + endBlock := uint32(toUint64(args[2])) + + if endBlock < startBlock { + panic("endBlock must be >= startBlock") + } + + summary := &state.TaxSummary{ + Account: account, + StartBlock: startBlock, + EndBlock: endBlock, + } + + // Iterate through all transactions in the period + for block := startBlock; block <= endBlock; block++ { + // Get cumulative tax for this block + taxKey := v.makeTaxWithheldKey(account, block) + taxData := ic.DAO.GetStorageItem(v.ID, taxKey) + if len(taxData) > 0 { + blockTax := new(big.Int).SetBytes(taxData) + summary.TaxWithheld.Add(&summary.TaxWithheld, blockTax) + } + + // Get transaction details + key := v.makeAccountTxIndexKey(account, block) + indexData := ic.DAO.GetStorageItem(v.ID, key) + + for i := 0; i+4 <= len(indexData); i += 4 { + txIndex := binary.BigEndian.Uint32(indexData[i : i+4]) + recordKey := v.makeTxRecordKey(block, txIndex) + recordData := ic.DAO.GetStorageItem(v.ID, recordKey) + + if len(recordData) > 0 { + item, err := stackitem.Deserialize(recordData) + if err == nil { + var record state.TransactionRecord + if record.FromStackItem(item) == nil { + if record.To.Equals(account) { + switch record.TxType { + case state.TxTypeIncome: + summary.TotalIncome.Add(&summary.TotalIncome, &record.Amount) + case state.TxTypeBenefit: + summary.TotalBenefits.Add(&summary.TotalBenefits, &record.Amount) + } + } + if record.From.Equals(account) && record.TxType == state.TxTypeExpense { + summary.TotalExpenses.Add(&summary.TotalExpenses, &record.Amount) + // Medical and Education expenses are typically deductible + if record.Category == state.CategoryMedical || record.Category == state.CategoryEducation { + summary.DeductibleExpenses.Add(&summary.DeductibleExpenses, &record.Amount) + } + } + } + } + } + } + } + + // Calculate estimated tax owed (simple flat rate calculation) + taxConfig := v.getTaxConfigInternal(ic.DAO) + if taxConfig.DefaultIncomeRate > 0 { + taxOwed := new(big.Int).Mul(&summary.TotalIncome, big.NewInt(int64(taxConfig.DefaultIncomeRate))) + taxOwed.Div(taxOwed, big.NewInt(10000)) + summary.EstimatedOwed = *taxOwed + } + + // Balance = TaxWithheld - EstimatedOwed (positive = refund due) + summary.Balance.Sub(&summary.TaxWithheld, &summary.EstimatedOwed) + + item, _ := summary.ToStackItem() + return item +} + +// ============ Public Internal Methods for Cross-Contract Use ============ + +// BalanceOf returns VTS balance for the account. +func (v *VTS) BalanceOf(d *dao.Simple, acc util.Uint160) *big.Int { + bal := v.getBalanceInternal(d, acc) + return bal.Total() +} + +// UnrestrictedBalanceOf returns unrestricted VTS balance for the account. +func (v *VTS) UnrestrictedBalanceOf(d *dao.Simple, acc util.Uint160) *big.Int { + bal := v.getBalanceInternal(d, acc) + return &bal.Unrestricted +} + +// Mint mints unrestricted VTS to the account. +func (v *VTS) Mint(ic *interop.Context, to util.Uint160, amount *big.Int) { + v.mintUnrestricted(ic, to, amount) +} + +// MintRestricted mints restricted VTS to the account. +func (v *VTS) MintRestricted(ic *interop.Context, to util.Uint160, amount *big.Int, category uint8) { + v.mintRestrictedInternal(ic, to, amount, category) +} + +// Burn burns unrestricted VTS from the account. +func (v *VTS) Burn(ic *interop.Context, from util.Uint160, amount *big.Int) { + bal := v.getBalanceInternal(ic.DAO, from) + if bal.Unrestricted.Cmp(amount) < 0 { + panic("insufficient funds") + } + bal.Unrestricted.Sub(&bal.Unrestricted, amount) + v.putBalance(ic.DAO, from, bal) + + supply := v.getTotalSupplyInternal(ic.DAO) + supply.Sub(supply, amount) + v.putTotalSupply(ic.DAO, supply) + + v.emitTransfer(ic, &from, nil, amount) +} + +// IsVendor returns true if the address is a registered active vendor. +func (v *VTS) IsVendor(d *dao.Simple, addr util.Uint160) bool { + vendor := v.getVendorInternal(d, addr) + return vendor != nil && vendor.Active +} + +// ============ Helper Functions ============ + +func checkWitness(ic *interop.Context, h util.Uint160) (bool, error) { + return runtime.CheckHashedWitness(ic, h) +} diff --git a/pkg/core/state/vts.go b/pkg/core/state/vts.go new file mode 100644 index 0000000..0516382 --- /dev/null +++ b/pkg/core/state/vts.go @@ -0,0 +1,452 @@ +package state + +import ( + "errors" + "fmt" + "math/big" + + "github.com/tutus-one/tutus-chain/pkg/util" + "github.com/tutus-one/tutus-chain/pkg/vm/stackitem" +) + +// Spending category constants (bitmask). +const ( + CategoryUnrestricted uint8 = 0 // No restrictions (standard money) + CategoryFood uint8 = 1 << 0 // Bit 0: Food purchases + CategoryShelter uint8 = 1 << 1 // Bit 1: Rent/utilities/housing + CategoryMedical uint8 = 1 << 2 // Bit 2: Healthcare expenses + CategoryEducation uint8 = 1 << 3 // Bit 3: Tuition/books/supplies + CategoryTransport uint8 = 1 << 4 // Bit 4: Transportation + CategoryAll uint8 = 0xFF // All categories +) + +// TxType represents the type of VTS transaction. +type TxType uint8 + +const ( + TxTypeTransfer TxType = 0 // P2P transfer (neutral) + TxTypeIncome TxType = 1 // Wages, sales, dividends (taxable) + TxTypeExpense TxType = 2 // Purchases, bills (potentially deductible) + TxTypeBenefit TxType = 3 // Government benefits (tax-exempt income) + TxTypeGift TxType = 4 // Gift (may have gift tax implications) + TxTypeTax TxType = 5 // Tax payment/withholding +) + +// VTSBalance represents the balance state of a VTS token holder. +// It tracks both unrestricted and category-restricted balances. +type VTSBalance struct { + Unrestricted big.Int // Unrestricted balance (can be spent anywhere) + Restricted map[uint8]*big.Int // Category -> restricted balance +} + +// NewVTSBalance creates a new empty VTSBalance. +func NewVTSBalance() *VTSBalance { + return &VTSBalance{ + Restricted: make(map[uint8]*big.Int), + } +} + +// Total returns the total balance (unrestricted + all restricted). +func (b *VTSBalance) Total() *big.Int { + total := new(big.Int).Set(&b.Unrestricted) + for _, amt := range b.Restricted { + total.Add(total, amt) + } + return total +} + +// ToStackItem converts VTSBalance to stackitem. +func (b *VTSBalance) ToStackItem() (stackitem.Item, error) { + // Format: [unrestricted, [[category, amount], [category, amount], ...]] + restrictedItems := make([]stackitem.Item, 0, len(b.Restricted)) + for cat, amt := range b.Restricted { + if amt.Sign() > 0 { + restrictedItems = append(restrictedItems, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(cat))), + stackitem.NewBigInteger(amt), + })) + } + } + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewBigInteger(&b.Unrestricted), + stackitem.NewArray(restrictedItems), + }), nil +} + +// FromStackItem converts stackitem to VTSBalance. +func (b *VTSBalance) FromStackItem(item stackitem.Item) error { + structItems, ok := item.Value().([]stackitem.Item) + if !ok || len(structItems) < 2 { + return errors.New("invalid VTSBalance structure") + } + + unrestricted, err := structItems[0].TryInteger() + if err != nil { + return fmt.Errorf("invalid unrestricted balance: %w", err) + } + b.Unrestricted = *unrestricted + + restrictedArr, ok := structItems[1].Value().([]stackitem.Item) + if !ok { + return errors.New("invalid restricted balances array") + } + + b.Restricted = make(map[uint8]*big.Int) + for _, ri := range restrictedArr { + pair, ok := ri.Value().([]stackitem.Item) + if !ok || len(pair) != 2 { + return errors.New("invalid restricted balance pair") + } + catBI, err := pair[0].TryInteger() + if err != nil { + return fmt.Errorf("invalid category: %w", err) + } + amt, err := pair[1].TryInteger() + if err != nil { + return fmt.Errorf("invalid amount: %w", err) + } + b.Restricted[uint8(catBI.Int64())] = amt + } + return nil +} + +// Vendor represents a registered vendor/merchant that can accept VTS payments. +type Vendor struct { + Address util.Uint160 // Vendor's script hash + Name string // Display name (max 64 chars) + Categories uint8 // Bitmask of accepted spending categories + RegisteredAt uint32 // Block height when registered + RegisteredBy util.Uint160 // Who registered this vendor + Active bool // Whether vendor is currently active +} + +// ToStackItem converts Vendor to stackitem. +func (v *Vendor) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewByteArray(v.Address.BytesBE()), + stackitem.NewByteArray([]byte(v.Name)), + stackitem.NewBigInteger(big.NewInt(int64(v.Categories))), + stackitem.NewBigInteger(big.NewInt(int64(v.RegisteredAt))), + stackitem.NewByteArray(v.RegisteredBy.BytesBE()), + stackitem.NewBool(v.Active), + }), nil +} + +// FromStackItem converts stackitem to Vendor. +func (v *Vendor) FromStackItem(item stackitem.Item) error { + structItems, ok := item.Value().([]stackitem.Item) + if !ok || len(structItems) < 6 { + return errors.New("invalid Vendor structure") + } + + addrBytes, err := structItems[0].TryBytes() + if err != nil { + return fmt.Errorf("invalid address: %w", err) + } + v.Address, err = util.Uint160DecodeBytesBE(addrBytes) + if err != nil { + return fmt.Errorf("invalid address bytes: %w", err) + } + + nameBytes, err := structItems[1].TryBytes() + if err != nil { + return fmt.Errorf("invalid name: %w", err) + } + v.Name = string(nameBytes) + + catBI, err := structItems[2].TryInteger() + if err != nil { + return fmt.Errorf("invalid categories: %w", err) + } + v.Categories = uint8(catBI.Int64()) + + regAtBI, err := structItems[3].TryInteger() + if err != nil { + return fmt.Errorf("invalid registeredAt: %w", err) + } + v.RegisteredAt = uint32(regAtBI.Int64()) + + regByBytes, err := structItems[4].TryBytes() + if err != nil { + return fmt.Errorf("invalid registeredBy: %w", err) + } + v.RegisteredBy, err = util.Uint160DecodeBytesBE(regByBytes) + if err != nil { + return fmt.Errorf("invalid registeredBy bytes: %w", err) + } + + active, err := structItems[5].TryBool() + if err != nil { + return fmt.Errorf("invalid active: %w", err) + } + v.Active = active + + return nil +} + +// TransactionRecord represents a recorded VTS transaction for tax accounting. +type TransactionRecord struct { + TxHash util.Uint256 // Transaction hash + BlockHeight uint32 // When it occurred + From util.Uint160 // Sender + To util.Uint160 // Recipient + Amount big.Int // Gross amount + TxType TxType // Type of transaction + Category uint8 // Spending category (if applicable) + TaxWithheld big.Int // Tax withheld at source + TaxRate uint16 // Rate applied (basis points, e.g., 2500 = 25%) + Memo string // Optional description (max 256 chars) +} + +// ToStackItem converts TransactionRecord to stackitem. +func (t *TransactionRecord) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewByteArray(t.TxHash.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(t.BlockHeight))), + stackitem.NewByteArray(t.From.BytesBE()), + stackitem.NewByteArray(t.To.BytesBE()), + stackitem.NewBigInteger(&t.Amount), + stackitem.NewBigInteger(big.NewInt(int64(t.TxType))), + stackitem.NewBigInteger(big.NewInt(int64(t.Category))), + stackitem.NewBigInteger(&t.TaxWithheld), + stackitem.NewBigInteger(big.NewInt(int64(t.TaxRate))), + stackitem.NewByteArray([]byte(t.Memo)), + }), nil +} + +// FromStackItem converts stackitem to TransactionRecord. +func (t *TransactionRecord) FromStackItem(item stackitem.Item) error { + structItems, ok := item.Value().([]stackitem.Item) + if !ok || len(structItems) < 10 { + return errors.New("invalid TransactionRecord structure") + } + + txHashBytes, err := structItems[0].TryBytes() + if err != nil { + return fmt.Errorf("invalid txHash: %w", err) + } + t.TxHash, err = util.Uint256DecodeBytesBE(txHashBytes) + if err != nil { + return fmt.Errorf("invalid txHash bytes: %w", err) + } + + blockHeightBI, err := structItems[1].TryInteger() + if err != nil { + return fmt.Errorf("invalid blockHeight: %w", err) + } + t.BlockHeight = uint32(blockHeightBI.Int64()) + + fromBytes, err := structItems[2].TryBytes() + if err != nil { + return fmt.Errorf("invalid from: %w", err) + } + t.From, err = util.Uint160DecodeBytesBE(fromBytes) + if err != nil { + return fmt.Errorf("invalid from bytes: %w", err) + } + + toBytes, err := structItems[3].TryBytes() + if err != nil { + return fmt.Errorf("invalid to: %w", err) + } + t.To, err = util.Uint160DecodeBytesBE(toBytes) + if err != nil { + return fmt.Errorf("invalid to bytes: %w", err) + } + + amount, err := structItems[4].TryInteger() + if err != nil { + return fmt.Errorf("invalid amount: %w", err) + } + t.Amount = *amount + + txTypeBI, err := structItems[5].TryInteger() + if err != nil { + return fmt.Errorf("invalid txType: %w", err) + } + t.TxType = TxType(txTypeBI.Int64()) + + catBI, err := structItems[6].TryInteger() + if err != nil { + return fmt.Errorf("invalid category: %w", err) + } + t.Category = uint8(catBI.Int64()) + + taxWithheld, err := structItems[7].TryInteger() + if err != nil { + return fmt.Errorf("invalid taxWithheld: %w", err) + } + t.TaxWithheld = *taxWithheld + + taxRateBI, err := structItems[8].TryInteger() + if err != nil { + return fmt.Errorf("invalid taxRate: %w", err) + } + t.TaxRate = uint16(taxRateBI.Int64()) + + memoBytes, err := structItems[9].TryBytes() + if err != nil { + return fmt.Errorf("invalid memo: %w", err) + } + t.Memo = string(memoBytes) + + return nil +} + +// TaxConfig represents the tax configuration for VTS. +type TaxConfig struct { + DefaultIncomeRate uint16 // Default income tax rate (basis points) + DefaultSalesRate uint16 // Default sales tax rate (basis points) + TreasuryAddress util.Uint160 // Where taxes are sent + ExemptCategories uint8 // Categories exempt from sales tax (bitmask) +} + +// ToStackItem converts TaxConfig to stackitem. +func (c *TaxConfig) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(c.DefaultIncomeRate))), + stackitem.NewBigInteger(big.NewInt(int64(c.DefaultSalesRate))), + stackitem.NewByteArray(c.TreasuryAddress.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(c.ExemptCategories))), + }), nil +} + +// FromStackItem converts stackitem to TaxConfig. +func (c *TaxConfig) FromStackItem(item stackitem.Item) error { + structItems, ok := item.Value().([]stackitem.Item) + if !ok || len(structItems) < 4 { + return errors.New("invalid TaxConfig structure") + } + + incomeRateBI, err := structItems[0].TryInteger() + if err != nil { + return fmt.Errorf("invalid defaultIncomeRate: %w", err) + } + c.DefaultIncomeRate = uint16(incomeRateBI.Int64()) + + salesRateBI, err := structItems[1].TryInteger() + if err != nil { + return fmt.Errorf("invalid defaultSalesRate: %w", err) + } + c.DefaultSalesRate = uint16(salesRateBI.Int64()) + + treasuryBytes, err := structItems[2].TryBytes() + if err != nil { + return fmt.Errorf("invalid treasuryAddress: %w", err) + } + c.TreasuryAddress, err = util.Uint160DecodeBytesBE(treasuryBytes) + if err != nil { + return fmt.Errorf("invalid treasuryAddress bytes: %w", err) + } + + exemptCatBI, err := structItems[3].TryInteger() + if err != nil { + return fmt.Errorf("invalid exemptCategories: %w", err) + } + c.ExemptCategories = uint8(exemptCatBI.Int64()) + + return nil +} + +// TaxSummary represents a tax summary for a specific period. +type TaxSummary struct { + Account util.Uint160 // Account address + StartBlock uint32 // Period start + EndBlock uint32 // Period end + TotalIncome big.Int // Total taxable income + TotalBenefits big.Int // Total tax-exempt benefits + TotalExpenses big.Int // Total expenses (for deductions) + DeductibleExpenses big.Int // Expenses that are deductible + TaxWithheld big.Int // Total tax withheld + EstimatedOwed big.Int // Estimated tax owed (income * rate) + Balance big.Int // TaxWithheld - EstimatedOwed +} + +// ToStackItem converts TaxSummary to stackitem. +func (s *TaxSummary) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewByteArray(s.Account.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(s.StartBlock))), + stackitem.NewBigInteger(big.NewInt(int64(s.EndBlock))), + stackitem.NewBigInteger(&s.TotalIncome), + stackitem.NewBigInteger(&s.TotalBenefits), + stackitem.NewBigInteger(&s.TotalExpenses), + stackitem.NewBigInteger(&s.DeductibleExpenses), + stackitem.NewBigInteger(&s.TaxWithheld), + stackitem.NewBigInteger(&s.EstimatedOwed), + stackitem.NewBigInteger(&s.Balance), + }), nil +} + +// FromStackItem converts stackitem to TaxSummary. +func (s *TaxSummary) FromStackItem(item stackitem.Item) error { + structItems, ok := item.Value().([]stackitem.Item) + if !ok || len(structItems) < 10 { + return errors.New("invalid TaxSummary structure") + } + + accountBytes, err := structItems[0].TryBytes() + if err != nil { + return fmt.Errorf("invalid account: %w", err) + } + s.Account, err = util.Uint160DecodeBytesBE(accountBytes) + if err != nil { + return fmt.Errorf("invalid account bytes: %w", err) + } + + startBI, err := structItems[1].TryInteger() + if err != nil { + return fmt.Errorf("invalid startBlock: %w", err) + } + s.StartBlock = uint32(startBI.Int64()) + + endBI, err := structItems[2].TryInteger() + if err != nil { + return fmt.Errorf("invalid endBlock: %w", err) + } + s.EndBlock = uint32(endBI.Int64()) + + totalIncome, err := structItems[3].TryInteger() + if err != nil { + return fmt.Errorf("invalid totalIncome: %w", err) + } + s.TotalIncome = *totalIncome + + totalBenefits, err := structItems[4].TryInteger() + if err != nil { + return fmt.Errorf("invalid totalBenefits: %w", err) + } + s.TotalBenefits = *totalBenefits + + totalExpenses, err := structItems[5].TryInteger() + if err != nil { + return fmt.Errorf("invalid totalExpenses: %w", err) + } + s.TotalExpenses = *totalExpenses + + deductible, err := structItems[6].TryInteger() + if err != nil { + return fmt.Errorf("invalid deductibleExpenses: %w", err) + } + s.DeductibleExpenses = *deductible + + taxWithheld, err := structItems[7].TryInteger() + if err != nil { + return fmt.Errorf("invalid taxWithheld: %w", err) + } + s.TaxWithheld = *taxWithheld + + estimatedOwed, err := structItems[8].TryInteger() + if err != nil { + return fmt.Errorf("invalid estimatedOwed: %w", err) + } + s.EstimatedOwed = *estimatedOwed + + balance, err := structItems[9].TryInteger() + if err != nil { + return fmt.Errorf("invalid balance: %w", err) + } + s.Balance = *balance + + return nil +}