Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .solhint.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"extends": "solhint:all",
"rules": {
"foundry-test-functions": "off",
"foundry-test-function-naming": "off",

"not-rely-on-time": "error",
"duplicated-imports": "error",
Expand Down
81 changes: 79 additions & 2 deletions contracts/CreditStation.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* CreditStation.sol - credit-station
* Copyright (C) 2025-Present SKALE Labs
* @author Dmytro Stebaiev
* @author Eduardo Vasques
*
* credit-station is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
Expand Down Expand Up @@ -32,21 +33,31 @@ import { EnumerableMap } from "@openzeppelin/contracts/utils/structs/EnumerableM
import { AddressIsNotSet } from "./interfaces/error.sol";
import { ICreditStation, IERC20 } from "./interfaces/ICreditStation.sol";
import { IVersioned } from "./interfaces/IVersioned.sol";
import { PaymentId, SchainHash } from "./interfaces/types.sol";

import { PaymentId, PaymentInfo, SchainHash } from "./interfaces/types.sol";
import { TypedMap } from "./structs/TypedMap.sol";

/// @title Credit Station
/// @author Dmytro Stebaiev
/// @author Eduardo Vasques
/// @notice This contract is responsible for receiving payments for credits.
contract CreditStation is AccessManaged, Pausable, IVersioned, ICreditStation {
using EnumerableMap for EnumerableMap.AddressToUintMap;
using TypedMap for TypedMap.AddressToPaymentIdArrayMap;

/// @notice Maximum number of queried items at once
uint256 public constant MAX_QUERY_SIZE = 10_000;

/// @notice The version of the contract
string public override version;
/// @notice Address that receives the payments for credits
address public receiver;

/// @notice Mapping from payment ID to payment information
mapping(PaymentId paymentId => PaymentInfo paymentInfo) public paymentsInfo;

PaymentId private _nextPaymentId = PaymentId.wrap(1);
EnumerableMap.AddressToUintMap private _prices;
TypedMap.AddressToPaymentIdArrayMap private _paymentsByUser;

/// @notice Emitted when a payment is received
/// @param id The payment ID
Expand Down Expand Up @@ -80,6 +91,9 @@ contract CreditStation is AccessManaged, Pausable, IVersioned, ICreditStation {

error TokenIsNotAccepted(IERC20 token);
error TokenTransferFailed(IERC20 token, address from, uint256 amount);
error NoPaymentsForUser(address user);
error InvalidIndices();
error PaymentIdDoesNotExist(PaymentId paymentId);

/// @notice Constructor
/// @param accessManagerAddress The address of the Access Manager contract
Expand Down Expand Up @@ -117,6 +131,15 @@ contract CreditStation is AccessManaged, Pausable, IVersioned, ICreditStation {
tokenAddress: token
});

_paymentsByUser.add(msg.sender, currentPaymentId);
paymentsInfo[currentPaymentId] = PaymentInfo({
schainHash: toSchainHash(schainName),
from: msg.sender,
to: purchaser,
blockNumber: block.number,
tokenAddress: token
});

require(token.transferFrom(msg.sender, receiver, price), TokenTransferFailed(token, msg.sender, price));
}

Expand Down Expand Up @@ -164,6 +187,60 @@ contract CreditStation is AccessManaged, Pausable, IVersioned, ICreditStation {

// External view

/// @notice Gets the number of payments made by a user
/// @param user The address of the buyer
/// @return numberOfPayments returns the number of payments made by the user
function getNumberOfPayments(
address user
) external view override returns (uint256 numberOfPayments) {
return _paymentsByUser.length(user);
}

/// @notice Gets the last payment made by a user
/// @param user The address of the buyer
/// @return paymentId returns the last payment ID if there is one, reverts otherwise
function getLastPayment(
address user
) external view override returns (PaymentId paymentId) {
uint256 length = _paymentsByUser.length(user);
require(length > 0, NoPaymentsForUser(user));
paymentId = _paymentsByUser.at(user, length - 1);
}

/// @notice Gets payment information by its id
/// @param user The address of the buyer
/// @param startIndex The start index (inclusive) of the payments to get
/// @param endIndex The end index (exclusive) of the payments to get
/// @return payments returns a list of payment IDs if there are any, reverts otherwise
function getPaymentIds(
address user,
uint256 startIndex,
uint256 endIndex
) external view override returns (PaymentId[] memory payments) {
uint256 len = _paymentsByUser.length(user);
if (len == 0){
return new PaymentId[](0);
}
require(startIndex < endIndex, InvalidIndices());
endIndex = endIndex - startIndex > MAX_QUERY_SIZE ? startIndex + MAX_QUERY_SIZE : endIndex;

// endIndex is adjusted to the length of the array in the TypedSet library, if required
return _paymentsByUser.values(user, startIndex, endIndex);
}

/// @notice Gets payment information by its id
/// @param paymentId The id of the payment
/// @return payment returns a payment if there is one, reverts otherwise
function getPaymentInfo(
PaymentId paymentId
) external view override returns (PaymentInfo memory payment) {
require(
paymentId < _nextPaymentId && PaymentId.wrap(0) < paymentId,
PaymentIdDoesNotExist(paymentId)
);
return paymentsInfo[paymentId];
}

/// @notice Gets price of credits batch in a specific token
/// @param token The address of the token
/// @return price The price of the credits batch in the specified token
Expand Down
32 changes: 31 additions & 1 deletion contracts/interfaces/ICreditStation.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* ICreditStation.sol - credit-station
* Copyright (C) 2025-Present SKALE Labs
* @author Dmytro Stebaiev
* @author Eduardo Vasques
*
* credit-station is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
Expand All @@ -25,10 +26,11 @@ pragma solidity ^0.8.30;

import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol";

import { SchainHash } from "./types.sol";
import { SchainHash, PaymentId, PaymentInfo } from "./types.sol";

/// @title Credit Station Interface
/// @author Dmytro Stebaiev
/// @author Eduardo Vasques
/// @notice Interface of the Credit Station contract
interface ICreditStation {
/// @notice Pay to get credits on an schain
Expand All @@ -52,6 +54,34 @@ interface ICreditStation {
function setReceiver(address newReceiver) external;
/// @notice Unpauses the contract
function unpause() external;
/// @notice Gets the number of payments made by a user
/// @param user The address of the buyer
/// @return numberOfPayments returns the number of payments made by the user
function getNumberOfPayments(
address user
) external view returns (uint256 numberOfPayments);
/// @notice Gets the last payment made by a user
/// @param user The address of the buyer
/// @return paymentId returns the last payment ID if there is one, reverts otherwise
function getLastPayment(
address user
) external view returns (PaymentId paymentId);
/// @notice Gets the payment IDs made by a user within a specific range (MAX 10_000 each query)
/// @param user The address of the buyer
/// @param startIndex The start index (inclusive) of the payments to get
/// @param endIndex The end index (exclusive) of the payments to get
/// @return payments returns a list of payment IDs if there are any, reverts otherwise
function getPaymentIds(
address user,
uint256 startIndex,
uint256 endIndex
) external view returns (PaymentId[] memory payments);
/// @notice Gets payment information by its id
/// @param paymentId The id of the payment
/// @return payment returns a payment if there is one, reverts otherwise
function getPaymentInfo(
PaymentId paymentId
) external view returns (PaymentInfo memory payment);
/// @notice Gets price of credits batch in a specific token
/// @param token The address of the token
/// @return price The price of the credits batch in the specified token
Expand Down
28 changes: 28 additions & 0 deletions contracts/interfaces/types.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* types.sol - credit-station
* Copyright (C) 2025-Present SKALE Labs
* @author Dmytro Stebaiev
* @author Eduardo Vasques
*
* credit-station is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
Expand All @@ -19,7 +20,34 @@
* along with credit-station. If not, see <https://www.gnu.org/licenses/>.
*/

// cspell:words IERC20

pragma solidity ^0.8.30;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

type PaymentId is uint256;
type SchainHash is bytes32;

using {
_paymentIdLess as <
} for PaymentId global;


struct PaymentInfo {
SchainHash schainHash;
address from;
address to;
uint256 blockNumber;
IERC20 tokenAddress;
}

/**
* @notice Checks if one PaymentId is less than another
* @param a The first PaymentId
* @param b The second PaymentId
* @return less True if `a` is less than `b`, false otherwise
*/
function _paymentIdLess(PaymentId a, PaymentId b) pure returns (bool less) {
return PaymentId.unwrap(a) < PaymentId.unwrap(b);
}
81 changes: 81 additions & 0 deletions contracts/structs/TypedMap.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: AGPL-3.0-only

/**
* TypedMap.sol - credit-station
* Copyright (C) 2026-Present SKALE Labs
* @author Eduardo Vasques
*
* credit-station is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* credit-station is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with credit-station. If not, see <https://www.gnu.org/licenses/>.
*/

pragma solidity ^0.8.30;

import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";

import { PaymentId } from "../interfaces/types.sol";


/// @title Typed Map
/// @author Eduardo Vasques
/// @notice A library for Custom Typed mappings
library TypedMap {
using EnumerableSet for EnumerableSet.UintSet;

struct AddressToPaymentIdArrayMap {
mapping(address key => PaymentId[] paymentIds) inner;
}

function add(
AddressToPaymentIdArrayMap storage map,
address key,
PaymentId value
) internal {
map.inner[key].push(value);
}

function at(
AddressToPaymentIdArrayMap storage map,
address key,
uint256 index
) internal view returns (PaymentId paymentId) {
return map.inner[key][index];
}

function length(
AddressToPaymentIdArrayMap storage map,
address key
) internal view returns (uint256 size) {
return map.inner[key].length;
}

function values(
AddressToPaymentIdArrayMap storage map,
address key,
uint256 startIndex,
uint256 endIndex
) internal view returns (PaymentId[] memory paymentIds) {
uint256 len = map.inner[key].length;

uint256 end = endIndex > len ? len : endIndex;
uint256 resultLength = end - startIndex;
paymentIds = new PaymentId[](resultLength);

for (uint256 i = 0; i < resultLength; ) {
paymentIds[i] = map.inner[key][startIndex + i];
unchecked {
++i;
}
}
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"globals": "^16.5.0",
"hardhat": "^2.27.0",
"mocha": "^11.7.5",
"solhint": "^6.0.1",
"solhint": "^6.0.2",
"solidity-coverage": "^0.8.16",
"ts-node": "^10.9.2",
"typechain": "^8.3.2",
Expand Down
60 changes: 59 additions & 1 deletion test/CreditStation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cleanMainnetDeployment, mainnetWithAllowedToken } from "./tools/fixtures";
import { ethers } from "hardhat";
import { should } from "chai";
import { expect, should } from "chai";

should();

Expand Down Expand Up @@ -30,4 +30,62 @@ describe("CreditStation", () => {
.should.emit(creditStation, "PaymentReceived")
.withArgs(1n, schainHash, user, user, token);
});

it("should get correct payment info", async () => {
const [,,user] = await ethers.getSigners();
const { creditStation, token } = await mainnetWithAllowedToken();
const price = await creditStation.getPrice(token);
const schain = "d2-chain";
const schainHash = await creditStation.toSchainHash(schain);
await token.mint(user, price * 2n);
await token.connect(user).approve(creditStation, price * 2n);
expect(await creditStation.getPaymentIds(user.address, 0n, 2n**256n - 1n)).to.deep.equal([]);

const buyTransaction = await creditStation.connect(user).buy(schain, user, token);
await buyTransaction.should.changeTokenBalance(
token,
await creditStation.receiver(),
price);
await buyTransaction
.should.emit(creditStation, "PaymentReceived")
.withArgs(1n, schainHash, user, user, token);
const paymentId = 1n;
const paymentInfo = await creditStation.getPaymentInfo(paymentId);
paymentInfo.schainHash.should.be.equal(schainHash);
paymentInfo.from.should.be.equal(user.address);

let lastPaymentId = await creditStation.getLastPayment(user.address);
lastPaymentId.should.be.equal(paymentId);

await creditStation.connect(user).buy(schain, user, token);

lastPaymentId = await creditStation.getLastPayment(user.address);
lastPaymentId.should.be.equal(paymentId + 1n);

expect(await creditStation.getPaymentIds(user.address, 0n, 2n)).to.deep.equal([1n, 2n]);
expect(await creditStation.getPaymentIds(user.address, 0n, 20_000n)).to.deep.equal([1n, 2n]);
expect(await creditStation.getPaymentIds(user.address, 2n, 20_000n)).to.deep.equal([]);
expect(await creditStation.getNumberOfPayments(user.address)).to.deep.equal(2n);

await creditStation.getPaymentIds(user.address, 3n, 2n).should.be.revertedWithCustomError(
creditStation,
"InvalidIndices"
);

await creditStation.getPaymentIds(user.address, 2n, 2n).should.be.revertedWithCustomError(
creditStation,
"InvalidIndices"
);
});

it("should revert when getting non-existing payment info", async () => {
const { creditStation } = await mainnetWithAllowedToken();
const nonExistingPaymentId = 9999n;
await creditStation.getPaymentInfo(nonExistingPaymentId)
.should.be.revertedWithCustomError(
creditStation,
"PaymentIdDoesNotExist"
)
.withArgs(nonExistingPaymentId);
});
});
Loading